/*
* Licensed to DuraSpace under one or more contributor license agreements.
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* DuraSpace licenses this file to you 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.fcrepo.http.api;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.when;
import org.fcrepo.http.api.PathLockManager.AcquiredLock;
import org.fcrepo.kernel.api.FedoraSession;
import org.fcrepo.kernel.api.exception.InterruptedRuntimeException;
import org.fcrepo.kernel.api.services.NodeService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
/**
* Unit tests for DefaultPathLockManager.
* @author Mike Durbin
*/
@RunWith(MockitoJUnitRunner.class)
public class DefaultPathLockManagerTest {
/**
* Miliseconds to allow (as a maximum) for running threads to complete. The
* current value of 1000 should be orders of magnitude more than is required.
* Tests are written such that we'll only wait this long if there's something
* broken in the code and the test would fail.
*/
public static final int WAIT = 1000;
@Mock
private FedoraSession session;
@Mock
private NodeService nodeService;
@Before
public void defaultSetup() {
when(nodeService.exists(any(), any())).thenReturn(true);
}
@Test
public void testActivePathCleanup() {
final DefaultPathLockManager m = new DefaultPathLockManager();
assertEquals("There should no active paths in memory.", 0, m.activePaths.size());
final AcquiredLock l1 = m.lockForRead("p1");
assertEquals("There should be exactly 1 path in memory.", 1, m.activePaths.size());
final AcquiredLock l2 = m.lockForWrite("p2", session, nodeService);
assertEquals("There should be exactly 2 paths in memory.", 2, m.activePaths.size());
l1.release();
assertEquals("There should be exactly 1 path in memory.", 1, m.activePaths.size());
l2.release();
assertEquals("There should no active paths in memory.", 0, m.activePaths.size());
}
@Test
public void readsShouldNotBlock() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String path = "path1";
m.lockForRead(path);
assertTrue("Concurrent read operations should be allowed!",
new Actor(() -> m.lockForRead(path)).canComplete());
}
@Test
public void readShouldBlockWhileWriting() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String path = "path1";
final AcquiredLock l = m.lockForWrite(path, session, nodeService);
final Actor r = new Actor(() -> m.lockForRead(path));
assertTrue("Read should block while writing to same path!", r.isBlocked());
l.release();
assertTrue("Read should complete after write!", r.canComplete());
}
@Test
public void writesShouldBlock() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String path = "path1";
final AcquiredLock l = m.lockForWrite(path, session, nodeService);
final Actor r = new Actor(() -> m.lockForWrite(path, session, nodeService));
assertTrue("Concurrent writes to the same path should block!", r.isBlocked());
l.release();
assertTrue("Write should be able to complete sequentially.", r.canComplete());
}
@Test
public void siblingWritesShouldNotBlock() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String p1 = "0/0";
final String p2 = "0/1";
m.lockForWrite(p1, session, nodeService);
final Actor writer = new Actor(() -> m.lockForWrite(p2, session, nodeService));
assertTrue("Sibling writes should not block!!", writer.canComplete());
}
@Test
public void siblingCreatesShouldNotBlock() {
when(nodeService.exists(any(), eq("0/0"))).thenReturn(false);
when(nodeService.exists(any(), eq("0/1"))).thenReturn(false);
final DefaultPathLockManager m = new DefaultPathLockManager();
final String p1 = "0/0";
final String p2 = "0/1";
m.lockForWrite(p1, session, nodeService);
final Actor writer = new Actor(() -> m.lockForWrite(p2, session, nodeService));
assertTrue("Sibling creates should not block!!", writer.canComplete());
}
@Test
public void deletePathShouldBeDisappearWhenLockIsReleased() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String p1 = "delete";
final AcquiredLock l = m.lockForDelete(p1);
assertEquals("One delete lock should exist!", 1, m.activeDeletePaths.size());
assertEquals(p1, m.activeDeletePaths.get(0));
l.release();
assertEquals("Delete lock should have been cleaned up!", 0, m.activeDeletePaths.size());
}
@Test
public void deleteShouldBlockAccessToDescendents() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String p1 = "delete";
m.lockForDelete(p1);
assertTrue("Reading a path that is being deleted should block until delete is complete!",
new Actor(() -> m.lockForRead("delete/some/ancestor")).isBlocked());
when(nodeService.exists(any(), eq("delete/some/nonexistant/path"))).thenReturn(false);
when(nodeService.exists(any(), eq("delete/some/nonexistant"))).thenReturn(false);
assertTrue("Creating a node under a node being deleted should block until delete is complete!",
new Actor(() -> m.lockForRead("delete/some/nonexistant/path")).isBlocked());
}
@Test
public void deleteShouldNotAffectParentOrPeers() {
final DefaultPathLockManager m = new DefaultPathLockManager();
final String p1 = "root/delete";
m.lockForDelete(p1);
assertTrue("Writing to parent of node-being-deleted should not block.",
new Actor(() -> m.lockForWrite("root", session, nodeService)).canComplete());
assertTrue("Writing to peer of node-being-deleted should not block.",
new Actor(() -> m.lockForWrite("root/other", session, nodeService)).canComplete());
}
/**
* An interface whose single method acquires an AcquiredLock.
*/
private interface Locker {
public AcquiredLock acquireLock();
}
/**
* A thread that locks as if performing some action.
*/
private class Actor extends Thread {
private boolean interrupted;
private Locker l;
public Actor(final Locker l) {
this.l = l;
this.start();
}
@Override
public void run() {
AcquiredLock lock = null;
try {
lock = l.acquireLock();
} catch (InterruptedRuntimeException e) {
interrupted = true;
}
if (lock != null) {
lock.release();
}
}
/**
* Determines if the thread would/was/is blocking. This
* is accomplished by interrupting the thread and joining,
* so once it's called, the thread is no longer of use
*/
private boolean isBlocked() {
this.interrupt();
try {
this.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return interrupted;
}
/**
* Determines if the thread has/can complete (ie, is not blocked).
* The current implementation joins this thread (with a timeout)
* and verifies that it is no longer alive.
* @return
*/
private boolean canComplete() {
try {
this.join(WAIT);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return !this.isAlive();
}
}
}