/* * ModeShape (http://www.modeshape.org) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.modeshape.jcr; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Random; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import javax.jcr.Node; import javax.jcr.PathNotFoundException; import javax.jcr.Property; import javax.jcr.PropertyType; import javax.jcr.RepositoryException; import javax.jcr.Session; import org.apache.tika.mime.MediaType; import org.junit.Assert; import org.junit.Test; import org.modeshape.common.FixFor; import org.modeshape.common.util.FileUtil; import org.modeshape.common.util.IoUtil; import org.modeshape.jcr.api.Binary; import org.modeshape.jcr.value.BinaryKey; import org.modeshape.jcr.value.BinaryValue; import org.modeshape.jcr.value.binary.BinaryStore; import org.modeshape.jcr.value.binary.BinaryStoreException; import com.amazonaws.AmazonClientException; import com.datastax.driver.core.exceptions.NoHostAvailableException; /** * Test suite that should include test cases which verify that a repository configured with various binary stores, correctly * stores the binary values. * * @author Horia Chiorean (hchiorea@redhat.com) */ public class BinaryStorageIntegrationTest extends SingleUseAbstractTest { private static final char[] CHARS = new char[] {'a', 'b', 'c'}; private static final Random RANDOM = new Random(); @Override public void beforeEach() throws Exception { // c3p0 is async, so it might take a bit until we can do this.... TestingUtil.waitUntilFolderCleanedUp("target/persistent_repository"); super.beforeEach(); } @Test @FixFor( "MODE-1786" ) public void shouldStoreBinariesIntoJDBCBinaryStore() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-jdbc-binary-storage.json"); byte[] data = randomBytes(4 * 1024); storeBinaryAndAssert(data, "node"); storeStringsAndAssert("stringNode"); } @Test @FixFor( "MODE-2200" ) public void shouldDecrementBinaryRefCountsWithFederations() throws Exception { new File("target/federation_persistent_3").mkdir(); startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs-connector3.json"); byte[] data = randomBytes(10); storeBinaryProperty(data, "skipNode1"); jcrSession().getNode("/skipNode1").remove(); jcrSession().save(); FileUtil.delete("target/persistent_repository/"); } @Test @FixFor( "MODE-2144" ) public void shouldCleanupUnusedBinariesForFilesystemStore() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); checkUnusedBinariesAreCleanedUp(); } @Test @FixFor( "MODE-2302" ) public void shouldReuseBinariesFromTrashForFilesystemStore() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); int repCount = 5; for (int i = 0; i < repCount; i++) { // upload a file which should mark the binary as used tools.uploadFile(session, "/file1.txt", resourceStream("io/file1.txt")); session.save(); BinaryValue storedValue = (BinaryValue)session.getProperty("/file1.txt/jcr:content/jcr:data").getBinary(); BinaryKey key = storedValue.getKey(); assertTrue("Binary not stored", binaryStore().hasBinary(key)); // remove the file, which should move the binary to trash session.getNode("/file1.txt").remove(); session.save(); } } @Test @FixFor( "MODE-2144" ) public void shouldCleanupUnusedBinariesForDatabaseStore() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-jdbc-binary-storage-other.json"); checkUnusedBinariesAreCleanedUp(); } @Test @FixFor( "MODE-2303" ) public void binaryUsageShouldChangeAfterSavingFS() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); checkBinaryUsageAfterSaving(); } @Test @FixFor( "MODE-2303" ) public void binaryUsageShouldChangeAfterSavingJDBC() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-jdbc-binary-storage.json"); checkBinaryUsageAfterSaving(); } @Test @FixFor( "MODE-2484 ") public void shouldModifyTheSameBinaryPropertiesFromMultipleThreadsWithoutDeadlocking() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); //verify we have no binaries yet (see afterEach) assertEquals(0, binariesCount()); int threadCount = 7; final List<String> filesToUpload = Arrays.asList("data/large-file1.png", "data/large-file2.jpg", "data/move-initial-data.xml"); // create some folders which will each contain files for (int i = 1; i < threadCount; i++) { session.getRootNode().addNode("folder_" + i, "nt:folder"); } session.save(); // now fire a number of different threads which should all link the same files in a different order from different (disjoint) // nodes ExecutorService executorService = Executors.newFixedThreadPool(threadCount); final CyclicBarrier barrier = new CyclicBarrier(threadCount); final List<Future<?>> results = new ArrayList<>(); final AtomicInteger folderCounter = new AtomicInteger(1); for (int i = 0; i < threadCount; i++) { results.add(executorService.submit(() -> { JcrSession session1 = repository.login(); List<String> localFilesToUpload = new ArrayList<>(filesToUpload); Collections.shuffle(localFilesToUpload); try { int folderIdx = folderCounter.getAndIncrement(); for (int i1 = 0; i1 < localFilesToUpload.size(); i1++) { tools.uploadFile(session1, "/folder_" + folderIdx + "/file_" + (i1 + 1), resourceStream(localFilesToUpload.get( i1))); } barrier.await(); session1.save(); return null; } finally { session1.logout(); } })); } try { for (Future<?> result : results) { result.get(3, TimeUnit.SECONDS); } // check that the correct number of binaries is stored assertEquals(filesToUpload.size(), binariesCount()); // now fire the same number of threads to remove the root nodes and therefore decrement the binary usage results.clear(); folderCounter.set(1); barrier.reset(); for (int i = 0; i < threadCount; i++) { results.add(executorService.submit(() -> { JcrSession session1 = repository.login(); try { int folderIdx = folderCounter.getAndIncrement(); session1.getNode("/folder_" + folderIdx).remove(); barrier.await(); session1.save(); return null; } finally { session1.logout(); } })); } // the remove should be a lot faster... for (Future<?> result : results) { result.get(1, TimeUnit.SECONDS); } // force a binary prune to validate that the binary values have been indeed marked as unused.... Thread.sleep(100); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); // we should've removed all the binaries since we marked them all as unused first.... assertEquals(0, binariesCount()); } finally { executorService.shutdownNow(); } } @Test @FixFor( "MODE-2484 ") public void shouldUpdateBinaryReferencesWhenChangingProperties() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); registerNodeTypes("cnd/multi_binary.cnd"); // verify we have no binaries yet (see afterEach) assertEquals(0, binariesCount()); Node multiBinaryNode = session.getRootNode().addNode("multiBinary", "test:multiBinary"); Binary binary1 = session.getValueFactory().createBinary(resourceStream("data/large-file1.png")); multiBinaryNode.setProperty("binary1", binary1); Binary binary2 = session.getValueFactory().createBinary(resourceStream("data/large-file2.jpg")); multiBinaryNode.setProperty("binary2", binary2); Binary binary3 = session.getValueFactory().createBinary(resourceStream("data/move-initial-data.xml")); multiBinaryNode.setProperty("binary3", binary3); session.save(); // we should have 3 binary properties assertEquals(3, binariesCount()); // set the first binary property to the same value as the third and force a cleanup to check one binary was marked as unused session.getNode("/multiBinary").setProperty("binary1", session.getValueFactory().createBinary(resourceStream( "data/move-initial-data.xml"))); session.save(); Thread.sleep(10); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); assertEquals(2, binariesCount()); // set the second binary property to the same value as the third and force a cleanup to check one binary was marked as unused session.getNode("/multiBinary").setProperty("binary2", session.getValueFactory().createBinary(resourceStream( "data/move-initial-data.xml"))); session.save(); Thread.sleep(10); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); assertEquals(1, binariesCount()); // now remove each property by setting it to null and check all binaries are removed session.getItem("/multiBinary/binary1").remove(); session.getItem("/multiBinary/binary2").remove(); session.getItem("/multiBinary/binary3").remove(); session.save(); Thread.sleep(10); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); assertEquals(0, binariesCount()); } @Test @FixFor( "MODE-2489" ) public void shouldSupportContentBasedTypeDetection() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-binaries-fs.json"); // upload 2 binaries but don't save tools.uploadFile(session, "/file1", resourceStream("io/file1.txt")); tools.uploadFile(session, "/file2", resourceStream("io/binary.pdf")); session.save(); Node content1 = session.getNode("/file1").getNode("jcr:content"); // even though the name of the file has no extension, full content based extraction should've been performed assertEquals(MediaType.TEXT_PLAIN.toString(), content1.getProperty("jcr:mimeType").getString()); Node content2 = session.getNode("/file2").getNode("jcr:content"); // even though the name of the file has no extension, full content based extraction should've been performed assertEquals("application/pdf", content2.getProperty("jcr:mimeType").getString()); } @Test @FixFor( "MODE-2489" ) public void shouldSupportNoMimeTypeDetection() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-no-mimetype-detection.json"); // upload 2 binaries but don't save tools.uploadFile(session, "/file1.txt", resourceStream("io/file1.txt")); session.save(); Node content = session.getNode("/file1.txt").getNode("jcr:content"); try { content.getProperty("jcr:mimeType"); fail("No mimetype should have been extracted"); } catch (PathNotFoundException e) { //expected } } @Test @FixFor( "MODE-2489" ) public void shouldSupportNameBasedTypeDetection() throws Exception { startRepositoryWithConfigurationFrom("config/repo-config-name-mimetype-detection.json"); tools.uploadFile(session, "/file1", resourceStream("io/file1.txt")); tools.uploadFile(session, "/file2.txt", resourceStream("io/file2.txt")); session.save(); Node content1 = session.getNode("/file1").getNode("jcr:content"); // because the name of the file does not have an extension, we're expecting a generic mime type here assertEquals("application/octet-stream", content1.getProperty("jcr:mimeType").getString()); Node content2 = session.getNode("/file2.txt").getNode("jcr:content"); // because the name of the file doe have an extension, we're expecting a specific mime type here assertEquals("text/plain", content2.getProperty("jcr:mimeType").getString()); } @Test(expected = NoHostAvailableException.class) public void shouldStartWithCassandraBinaryStore() throws Exception { // we expect cassandra to fail because we're not starting a cassandra server, but we want to check that we get that far ;) startRepositoryWithConfigurationFrom("config/cassandra-binary-storage.json"); } @Test public void shouldStartWithMongoBinaryStore() throws Exception { // even though we don't start mongo in this test, the binary store is initialized ;) startRepositoryWithConfigurationFrom("config/mongo-binary-storage.json"); } @Test public void shouldStartWithS3BinaryStore() throws Exception { try { // even though we don't connect to s3 in this test, the binary store is initialized ;) startRepositoryWithConfigurationFrom("config/s3-binary-storage.json"); } catch (BinaryStoreException e) { if (e.getCause() instanceof AmazonClientException && e.getCause().getCause() instanceof IOException) { System.err.println("Ignoring Amazon S3 integration test because there's a network issue..."); } else { throw e; } } } private String randomString(long size) { StringBuilder builder = new StringBuilder(""); while (builder.length() < size) { builder.append(CHARS[RANDOM.nextInt(3)]); } return builder.toString(); } private void storeStringsAndAssert( String stringNode ) throws Exception { Node testRoot = jcrSession().getRootNode().addNode(stringNode); long minStringSize = repository.getConfiguration().getBinaryStorage().getMinimumStringSize(); //the small string should be stored as a string String smallString = randomString(minStringSize - 1); testRoot.setProperty("smallString", smallString); //the large string should be stored as a binary String largeString = randomString(minStringSize + 1); testRoot.setProperty("largeString", largeString); jcrSession().save(); //use a separate session to validate because the original one still caches the properties as string... JcrSession readerSession = repository.login(); try { Property smallStringProperty = readerSession.getProperty("/" + stringNode + "/smallString"); assertEquals("Small string should've been stored as a string", PropertyType.STRING, smallStringProperty.getType()); assertEquals("Incorrect stored string value", smallString, smallStringProperty.getString()); Property largeStringProperty = readerSession.getProperty("/" + stringNode + "/largeString"); assertEquals("Large string should've been stored as a binary", PropertyType.BINARY, largeStringProperty.getType()); String binaryStringValue = IoUtil.read(largeStringProperty.getBinary().getStream()); assertEquals("Incorrect stored string value", largeString, binaryStringValue); } finally { readerSession.logout(); } } private void checkBinaryUsageAfterSaving() throws Exception { assertEquals("There should be no binaries in store", 0, binariesCount()); // upload 2 binaries but don't save tools.uploadFile(session, "/file1.txt", resourceStream("io/file1.txt")); tools.uploadFile(session, "/file2.txt", resourceStream("io/file2.txt")); // run a cleanup Thread.sleep(2); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); // check that the binaries have been removed assertEquals("There should be no binaries in store", 0, binariesCount()); // discard all previous changes session.refresh(false); // upload a new file tools.uploadFile(session, "/file3.txt", resourceStream("io/file3.txt")); // and save session.save(); // now check there is a binary assertEquals(1, binariesCount()); // run a cleanup Thread.sleep(2); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); //check the binary are still there assertEquals(1, binariesCount()); } private void checkUnusedBinariesAreCleanedUp() throws Exception { Session session = repository.login(); try { assertEquals("There should be no binaries in store", 0, binariesCount()); tools.uploadFile(session, "/file1.txt", resourceStream("io/file1.txt")); tools.uploadFile(session, "/file2.txt", resourceStream("io/file2.txt")); Node node3 = session.getRootNode().addNode("file3"); node3.setProperty("binary", session.getValueFactory().createBinary(resourceStream("io/file3.txt"))); session.save(); // verify the mime-type has been extracted for the 2 nt:resources Binary file1Binary = (Binary)session.getNode("/file1.txt/jcr:content").getProperty("jcr:data").getBinary(); assertEquals("Invalid mime-type", "text/plain", file1Binary.getMimeType()); Binary file2Binary = (Binary)session.getNode("/file2.txt/jcr:content").getProperty("jcr:data").getBinary(); assertEquals("Invalid mime-type", "text/plain", file2Binary.getMimeType()); // expect 3 binaries assertEquals("Incorrect number of binaries in store", 3, binariesCount()); session.removeItem("/file1.txt"); session.removeItem("/file2.txt"); session.save(); Thread.sleep(2); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); // expect 1 binary assertEquals("Incorrect number of binaries in store", 1, binariesCount()); session.removeItem("/file3/binary"); session.save(); // sleep to give the binary change listener to mark the binaries as unused Thread.sleep(100); binaryStore().removeValuesUnusedLongerThan(1, TimeUnit.MILLISECONDS); assertEquals("There should be no binaries in store", 0, binariesCount()); } finally { session.logout(); } } private int binariesCount() throws Exception { int count = 0; for (BinaryKey binaryKey : binaryStore().getAllBinaryKeys()) { if (binaryKey != null) count++; } return count; } private BinaryStore binaryStore() { return repository.runningState().binaryStore(); } private byte[] randomBytes( int size ) { byte[] data = new byte[size]; RANDOM.nextBytes(data); return data; } private void storeBinaryAndAssert( byte[] data, String nodeName ) throws RepositoryException, IOException { InputStream stream = storeBinaryProperty(data, nodeName); byte[] storedData = IoUtil.readBytes(stream); assertArrayEquals("Data retrieved does not match data stored", data, storedData); } private InputStream storeBinaryProperty( byte[] data, String nodeName ) throws RepositoryException { Node testRoot = jcrSession().getRootNode().addNode(nodeName); testRoot.setProperty("binary", session.getValueFactory().createValue(new ByteArrayInputStream(data))); jcrSession().save(); Property binary = jcrSession().getNode("/" + nodeName).getProperty("binary"); Assert.assertNotNull(binary); return binary.getBinary().getStream(); } @Override protected boolean startRepositoryAutomatically() { return false; } }