/* The contents of this file are subject to the license and copyright terms
* detailed in the license directory at the root of the source tree (also
* available online at http://fedora-commons.org/license/).
*/
package org.fcrepo.server.storage;
import static junit.framework.Assert.fail;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.fcrepo.common.PID;
import org.fcrepo.server.Context;
import org.fcrepo.server.Server;
import org.fcrepo.server.errors.ObjectExistsException;
import org.fcrepo.server.errors.ObjectLockedException;
import org.fcrepo.server.errors.ServerException;
import org.fcrepo.server.errors.GeneralException;
import org.fcrepo.server.management.BasicPIDGenerator;
import org.fcrepo.server.management.ManagementModule;
import org.fcrepo.server.resourceIndex.ResourceIndexModule;
import org.fcrepo.server.search.FieldSearch;
import org.fcrepo.server.storage.lowlevel.DefaultLowlevelStorageModule;
import org.fcrepo.server.storage.translation.DOTranslatorModule;
import org.fcrepo.server.storage.translation.DOTranslationUtility;
import org.fcrepo.server.storage.types.XMLDatastreamProcessor;
import org.fcrepo.server.storage.types.BasicDigitalObject;
import org.fcrepo.server.utilities.SQLUtility;
import org.fcrepo.server.validation.DOObjectValidatorModule;
import org.fcrepo.server.validation.DOValidatorModule;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RunWith(PowerMockRunner.class)
@PowerMockIgnore({"org.slf4j.*", "org.apache.xerces.*", "javax.xml.*",
"org.xml.sax.*", "javax.management.*"})
@PrepareForTest({Server.class, SQLUtility.class})
public class DefaultDOManagerTest
{
private static final Logger LOGGER =
LoggerFactory.getLogger(DefaultDOManagerTest.class);
private final String FORMAT = "info:fedora/fedora-system:FOXML-1.1";
private final String ENCODING = "UTF-8";
private static final String DUMMY_PID = "obj:1";
private static final PID DUMMY_PID_OBJECT = PID.getInstance(DUMMY_PID);
private static final FedoraStorageHintProvider DUMMY_HINTS =
new NullStorageHintsProvider();
@Mock
private Server mockServer;
@Mock
private Context mockContext;
@Mock
private ManagementModule mockManagement;
@Mock
private DefaultExternalContentManager mockExternalContent;
@Mock
private BasicPIDGenerator mockPidGenerator;
@Mock
private DOTranslatorModule mockTranslatorModule;
@Mock
private DOValidatorModule mockValidatorModule;
@Mock
private DOObjectValidatorModule mockObjectValidatorModule;
@Mock
private ResourceIndexModule mockResourceIndexModule;
@Mock
private ConnectionPoolManagerImpl mockConnectionPoolManager;
@Mock
private ConnectionPool mockPool;
@Mock
private Connection mockROConnection;
@Mock
private DefaultLowlevelStorageModule mockLowLevelStorage;
private DOReaderCache mockReaderCache = new DOReaderCache();
@Mock
private ResultSet pidExists;
@Mock
private FieldSearch mockFieldSearch;
private DefaultDOManager testObj;
/**
* Reach into the clazz and set a static member's value
* @param clazz
* @param name
* @param instance
*/
private static void setStaticMember(Class<?> clazz, String name, Object instance) {
try {
Field instanceField = clazz.getDeclaredField(name);
instanceField.setAccessible(true);
instanceField.set(null, instance);
} catch (SecurityException e) {
fail("Failed to set static member: " + e);
} catch (NoSuchFieldException e) {
fail("Failed to set static member: " + e);
} catch (IllegalArgumentException e) {
e.printStackTrace();
fail("Failed to set static member: " + e);
} catch (IllegalAccessException e) {
fail("Failed to set static member: " + e);
}
}
@Before
public void setUp() throws Exception {
// Easiest to just short-circuit FEDORA_HOME in XMLDatastreamProcessor
setStaticMember(XMLDatastreamProcessor.class, "initialized", true);
setStaticMember(XMLDatastreamProcessor.class, "DC_DEFAULT_CONTROLGROUP", "X");
setStaticMember(XMLDatastreamProcessor.class, "RELS_DEFAULT_CONTROLGROUP", "X");
// Server.getPID must be overridden
mockStatic(Server.class);
when(Server.getPID(any(String.class))).thenReturn(DUMMY_PID_OBJECT);
testObj = getInstance();
}
DefaultDOManager getInstance() throws Exception {
final Map<String, String> dummyParams = new HashMap<String,String>();
dummyParams.put("pidNamespace", "changeme");
dummyParams.put("defaultExportFormat", "info:fedora/fedora-system:FOXML-1.1");
final DefaultDOManager instance = new DefaultDOManager(dummyParams, mockServer, "DOManager");
instance.initModule();
// postInitModule expectations
when(mockServer.getModule("org.fcrepo.server.management.Management"))
.thenReturn(mockManagement);
when(mockServer.getModule("org.fcrepo.server.storage.ExternalContentManager"))
.thenReturn(mockExternalContent);
when(mockServer.getModule("org.fcrepo.server.management.PIDGenerator")).thenReturn(mockPidGenerator);
when(mockServer.getModule("org.fcrepo.server.storage.translation.DOTranslator")).thenReturn(mockTranslatorModule);
when(mockServer.getModule("org.fcrepo.server.validation.DOValidator")).thenReturn(mockValidatorModule);
when(mockServer.getModule("org.fcrepo.server.validation.DOObjectValidator")).thenReturn(mockObjectValidatorModule);
when(mockServer.getModule("org.fcrepo.server.resourceIndex.ResourceIndex")).thenReturn(mockResourceIndexModule);
when(mockServer.getModule("org.fcrepo.server.storage.ConnectionPoolManager")).thenReturn(mockConnectionPoolManager);
when(mockServer.getModule("org.fcrepo.server.storage.lowlevel.ILowlevelStorage")).thenReturn(mockLowLevelStorage);
when(mockServer.getBean("org.fcrepo.server.search.FieldSearch", FieldSearch.class))
.thenReturn(mockFieldSearch);
when(mockServer.getBean("fedoraStorageHintProvider")).thenReturn(DUMMY_HINTS);
when(mockServer.getBean("org.fcrepo.server.readerCache")).thenReturn(mockReaderCache);
when(mockConnectionPoolManager.getPool()).thenReturn(mockPool);
when(mockConnectionPoolManager.getPool(anyString())).thenReturn(mockPool);
when(mockPool.getReadOnlyConnection()).thenReturn(mockROConnection);
mockStatic(SQLUtility.class);
when(mockServer.getModule("org.fcrepo.server.storage.DOManager")).thenReturn(instance);
PreparedStatement mockStmt = mock(PreparedStatement.class);
ResultSet mockResult = mock(ResultSet.class);
when(mockStmt.executeQuery()).thenReturn(mockResult);
when(mockROConnection.prepareStatement(
eq(DefaultDOManager.CMODEL_QUERY),
eq(ResultSet.TYPE_FORWARD_ONLY),
eq(ResultSet.CONCUR_READ_ONLY)))
.thenReturn(mockStmt);
PreparedStatement mockExistsStmt = mock(PreparedStatement.class);
when(mockExistsStmt.executeQuery()).thenReturn(pidExists);
when(mockROConnection.prepareStatement(eq(DefaultDOManager.REGISTERED_PID_QUERY)))
.thenReturn(mockExistsStmt);
instance.postInitModule();
return instance;
}
@Test
public void testGetIngestWriterSucceeds()
throws Exception {
InputStream in = new ByteArrayInputStream("".getBytes(ENCODING));
when(pidExists.next()).thenReturn(false).thenReturn(true);
Connection mockRWConnection = mock(Connection.class);
when(mockPool.getReadWriteConnection()).thenReturn(mockRWConnection);
PreparedStatement mockInsert = mock(PreparedStatement.class);
when(mockRWConnection.prepareStatement(
eq(DefaultDOManager.INSERT_PID_QUERY))).thenReturn(mockInsert);
testObj.getIngestWriter(Server.USE_DEFINITIVE_STORE, mockContext, in, FORMAT, ENCODING, DUMMY_PID);
verify(mockInsert).executeUpdate();
}
@Test (expected=ObjectExistsException.class)
public void testGetIngestWriterThrowsIfPidAlreadyRegistered() throws Exception
{
InputStream in = new ByteArrayInputStream("".getBytes(ENCODING));
when(pidExists.next()).thenReturn(true);
testObj.getIngestWriter(Server.USE_DEFINITIVE_STORE, mockContext, in, FORMAT, ENCODING, DUMMY_PID);
}
@Test (expected=ObjectExistsException.class)
public void testGetIngestWriterThrowsIfObjectAlreadyExists() throws Exception
{
InputStream in = new ByteArrayInputStream("".getBytes(ENCODING));
when(pidExists.next()).thenReturn(false);
when(mockLowLevelStorage.objectExists(DUMMY_PID)).thenReturn(true);
testObj.getIngestWriter(Server.USE_DEFINITIVE_STORE, mockContext, in, FORMAT, ENCODING, DUMMY_PID);
}
@Test
public void testMultithreadedThreadSwitchesBetweenCheckAndRegisterObject() throws Throwable {
// mock the changing result of the existing pid check
final AtomicBoolean registered = new AtomicBoolean(false);
final CountDownLatch latch = new CountDownLatch(1);
// because we want to test a situation in which registerObject() is called
// by a parallel thread before objectExists() finishes, so we cache the
// existence check, and wait on a countdown latch in registerObject.
// If the object locking strategy is ineffective, then the two threads
// will both be able to proceed as if the object did not exist,
// resulting in an unexpected storage error from registerObject
when(pidExists.next()).thenAnswer(new Answer<Boolean>(){
@Override
public Boolean answer(InvocationOnMock invocation) throws Throwable {
boolean result = registered.get();
LOGGER.debug("pidExists returning {}, waiting...", result);
return result;
}
});
// mock the read/write connection to insert the new pid
Connection mockRWConnection = mock(Connection.class);
when(mockPool.getReadWriteConnection()).thenReturn(mockRWConnection);
final PreparedStatement mockInsert = mock(PreparedStatement.class);
when(mockRWConnection.prepareStatement(
eq(DefaultDOManager.INSERT_PID_QUERY))).thenAnswer(
new Answer<PreparedStatement>() {
@Override
public PreparedStatement answer(
InvocationOnMock invocation)
throws Throwable {
latch.await();
if (registered.getAndSet(true)) {
throw new SQLException("object already exists!");
} else {
return mockInsert;
}
}
});
PreparedStatement mockVersionQuery = mock(PreparedStatement.class);
when(mockRWConnection.prepareStatement(DefaultDOManager.PID_VERSION_QUERY))
.thenReturn(mockVersionQuery);
ResultSet versionResults = mock(ResultSet.class);
when(versionResults.next()).thenReturn(true).thenReturn(false);
when(mockVersionQuery.executeQuery()).thenReturn(versionResults);
PreparedStatement mockVersionUpdate = mock(PreparedStatement.class);
when(mockRWConnection.prepareStatement(DefaultDOManager.PID_VERSION_UPDATE))
.thenReturn(mockVersionUpdate);
ThreadSwitchRunnable t1 = new ThreadSwitchRunnable(testObj);
ThreadSwitchRunnable t2 = new ThreadSwitchRunnable(testObj);
Thread t1t = new Thread(t1);
Thread t2t = new Thread(t2);
t1t.start();
t2t.start();
// release the threads!
latch.countDown();
t1t.join();
t2t.join();
int successes = t1.successes.get() + t2.successes.get();
int expectedFailures = t1.expectedFailures.get() + t2.expectedFailures.get();
int unexpectedFailures = t1.unexpectedFailures.get() + t2.unexpectedFailures.get();
assertEquals( 1, successes );
assertEquals( 1, expectedFailures );
assertEquals( 0, unexpectedFailures );
assertTrue((t1.successes.get() ==1) ^ (t1.expectedFailures.get() == 1));
assertTrue((t2.successes.get() ==1) ^ (t2.expectedFailures.get() == 1));
}
class ThreadSwitchRunnable implements Runnable {
DefaultDOManager manager;
AtomicInteger successes = new AtomicInteger();
AtomicInteger expectedFailures = new AtomicInteger();
AtomicInteger unexpectedFailures = new AtomicInteger();
ThreadSwitchRunnable(DefaultDOManager manager) {
this.manager = manager;
}
public void run() {
InputStream in = null;
try {
in = new ByteArrayInputStream("".getBytes(ENCODING));
DOWriter ingestWriter = manager.getIngestWriter(Server.USE_DEFINITIVE_STORE, mockContext, in, FORMAT, ENCODING, DUMMY_PID);
ingestWriter.commit( "" );
manager.releaseWriter( ingestWriter );
successes.incrementAndGet();
LOGGER.info( "{} - thread task completed", Thread.currentThread().getName() );
try {
in.close();
} catch( IOException ex ) {
ex.printStackTrace();
}
} catch ( ObjectLockedException ole ) {
LOGGER.info( "{} - thread caught expected exception: {}", Thread.currentThread().getName(), ole );
expectedFailures.incrementAndGet();
try {
in.close();
} catch( IOException ioe ) {
ole.printStackTrace();
}
} catch (ObjectExistsException oee ) {
LOGGER.info( "{} - thread caught expected exception: {}", Thread.currentThread().getName(), oee );
expectedFailures.incrementAndGet();
try {
in.close();
} catch( IOException ioe ) {
oee.printStackTrace();
}
} catch( Exception ex ) {
LOGGER.error( Thread.currentThread().getName() + " - Exception", ex);
unexpectedFailures.incrementAndGet();
try {
in.close();
} catch( IOException ioe ) {
ex.printStackTrace();
}
}
}
}
@Test
public void testMultithreadedGetWriterBlocksReadsForSameObject() throws Throwable {
final AtomicInteger retrievals = new AtomicInteger();
when(mockLowLevelStorage.retrieveObject(DUMMY_PID)).thenAnswer(
new Answer<ByteArrayInputStream>() {
@Override
public ByteArrayInputStream answer(InvocationOnMock invocation) throws Throwable {
retrievals.incrementAndGet();
return new ByteArrayInputStream("".getBytes(ENCODING));
}
}
);
doAnswer(
new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
BasicDigitalObject obj = (BasicDigitalObject) invocation.getArguments()[1];
obj.setPid(DUMMY_PID);
return null;
}
}
).when(mockTranslatorModule).deserialize(
any(InputStream.class), any(BasicDigitalObject.class), eq(FORMAT), eq(ENCODING),
eq(DOTranslationUtility.DESERIALIZE_INSTANCE));
GetWriterRunnable t1 = new GetWriterRunnable(testObj);
GetWriterRunnable t2 = new GetWriterRunnable(testObj);
Thread t1t = new Thread(t1);
Thread t2t = new Thread(t2);
t1t.start();
t2t.start();
// Both threads will try to get a writer on the same PID, but one should have
// to wait. Since GetWriterRunnable does not release its writer, interrupt the
// threads after a short period of time.
try {
t1t.join(100);
t2t.join(100);
} catch (InterruptedException e) {}
int successes = t1.successes.get() + t2.successes.get();
int unexpectedFailures = t1.unexpectedFailures.get() + t2.unexpectedFailures.get();
// Exactly one thread should have succeeded in getting a writer and exactly
// one object retrieval should have occurred. The other thread should have been
// waiting its turn.
assertEquals(1, retrievals.get());
assertEquals(1, successes);
assertEquals(0, unexpectedFailures);
}
@Test
public void testGetWriterUnlocksForException() throws Throwable {
doAnswer(
new Answer<Void>() {
private boolean thrown = false;
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
// Throw an exception on the first try, succeed on the second.
if (thrown == false) {
thrown = true;
throw new GeneralException("Expected server exception");
} else {
BasicDigitalObject obj = (BasicDigitalObject) invocation.getArguments()[1];
obj.setPid(DUMMY_PID);
}
return null;
}
}
).when(mockTranslatorModule).deserialize(
any(InputStream.class), any(BasicDigitalObject.class), eq(FORMAT), eq(ENCODING),
eq(DOTranslationUtility.DESERIALIZE_INSTANCE));
// Our first try should result in an exception, which should unlock the object.
GetWriterRunnable r1 = new GetWriterRunnable(testObj);
Thread t1 = new Thread(r1);
t1.start();
try {
t1.join(100);
} catch (InterruptedException e) {}
assertEquals(0, r1.successes.get());
assertEquals(1, r1.expectedFailures.get());
assertEquals(0, r1.unexpectedFailures.get());
// Our second try should succeed, because the object should have been unlocked.
GetWriterRunnable r2 = new GetWriterRunnable(testObj);
Thread t2 = new Thread(r2);
t2.start();
try {
t2.join(100);
} catch (InterruptedException e) {}
assertEquals(1, r2.successes.get());
assertEquals(0, r2.expectedFailures.get());
assertEquals(0, r2.unexpectedFailures.get());
}
class GetWriterRunnable implements Runnable {
DefaultDOManager manager;
AtomicInteger successes = new AtomicInteger();
AtomicInteger expectedFailures = new AtomicInteger();
AtomicInteger unexpectedFailures = new AtomicInteger();
GetWriterRunnable(DefaultDOManager manager) {
this.manager = manager;
}
public void run() {
try {
// Get a writer and keep it forever
manager.getWriter(false, mockContext, DUMMY_PID);
successes.incrementAndGet();
} catch (ServerException ex) {
LOGGER.info( "{} - thread caught expected exception: {}", Thread.currentThread().getName(), ex );
expectedFailures.incrementAndGet();
} catch (Exception ex) {
LOGGER.error(Thread.currentThread().getName() + " - Exception", ex);
unexpectedFailures.incrementAndGet();
}
}
}
// Supports legacy test runners
public static junit.framework.Test suite() {
return new junit.framework.JUnit4TestAdapter(DefaultDOManagerTest.class);
}
}