/*
* 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.slf4j.LoggerFactory.getLogger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.fcrepo.kernel.api.FedoraSession;
import org.fcrepo.kernel.api.exception.InterruptedRuntimeException;
import org.fcrepo.kernel.api.services.NodeService;
import org.slf4j.Logger;
import org.springframework.stereotype.Component;
import com.google.common.annotations.VisibleForTesting;
/**
* A class that serves as a pool lockable paths to guarantee synchronized
* accesses to the resources at those paths. Because there may be an
* extremely high number of paths in the repository, this implementation
* is complicated by a need to ensure that for a given path, only one set
* of locks exists and only until all the locks have been acquired and released.
*
* Because this is very complex code, extensive logging is produced at
* the TRACE level.
*
* @author Mike Durbin
*/
@Component
public class DefaultPathLockManager implements PathLockManager {
private static final Logger LOGGER = getLogger(DefaultPathLockManager.class);
/**
* A map of all the paths for which requests to lock are underway. This
* is an exhaustive set of all paths that may be accessed at this time.
* Changes to the contents of this map must be synchronized on the instance
* of this class, though blocking acquisition of locks against those paths
* must NOT be, lest we degrade to an essentially single-threaded
* application.
*/
@VisibleForTesting
Map<String, ActivePath> activePaths = new HashMap<>();
/**
* A list of paths for which delete operations are underway. Attempts to
* acquire locks on resources that would be deleted with those paths will
* block until the delete lock is released. This is a handy shortcut that
* allows this class to meet the locking requirements for delete operations
* without actually discovering (and locking) all the descendant nodes.
*/
@VisibleForTesting
List<String> activeDeletePaths = new ArrayList<>();
/**
* A class that represents a path that can be locked for reading or writing
* and is the subject of a currently active lock request (though locks may
* not necessarily have been granted yet).
*/
private class ActivePath {
private String path;
private ReadWriteLock rwLock;
/**
* A list with references to every thread that has requested
* (though not necessary acquired) a read or write lock through
* the mechanism of the outer class' contract and not yet
* relinquished it by invoking LockManager.release(). This
* list is actually managed by methods outside of ActivePath.
*/
private List<Thread> threads;
private ActivePath(final String path) {
this.path = path;
rwLock = new ReentrantReadWriteLock();
threads = new ArrayList<>();
}
public PathScopedLock getReadLock() {
threads.add(Thread.currentThread());
LOGGER.trace("Thread {} requesting read lock on {}.", Thread.currentThread().getId(), path);
return new PathScopedLock(rwLock.readLock());
}
public PathScopedLock getWriteLock() {
threads.add(Thread.currentThread());
LOGGER.trace("Thread {} requesting write lock on {}.", Thread.currentThread().getId(), path);
return new PathScopedLock(rwLock.writeLock());
}
/**
* Wraps a lock (read or write) to expose a subset of its
* functionality and to add special handling for our "delete"
* locks.
*/
private class PathScopedLock {
final private Lock lock;
public PathScopedLock(final Lock l) {
lock = l;
}
public ActivePath getPath() {
return ActivePath.this;
}
public boolean tryLock() {
for (final String deletePath : activeDeletePaths) {
if (isOrIsDescendantOf(path, deletePath)) {
LOGGER.trace("Thread {} could not be granted lock on {} because that path is being deleted.",
Thread.currentThread().getId(), path);
return false;
}
}
return lock.tryLock();
}
public void unlock() {
lock.unlock();
}
}
}
/**
* The AcquiredLock implementation that's returned by the surrounding class.
* Two main constructors exist, one that accepts a bunch of locks, all of which
* must be acquired and a second representing a "delete" lock, for which at
* acquisition time the locks necessary are determined from the current pool of
* active locks.
*
* Never, outside of a block of code synchronized with the surrounding class
* instance, does this class hold an incomplete subset of the locks required:
* in other words, it gets all of the locks or none of them, never blocking
* while holding locks.
*/
private class AcquiredMultiPathLock implements AcquiredLock {
private String deletePath;
private List<ActivePath.PathScopedLock> locks;
/**
* Instantiates and initializes an AcquiredMultiPathLock. This
* constructor blocks until all of the necessary locks have been
* acquired, but to avoid possible deadlocks releases all acquired
* locks when it fails to acquire even one of them.
* @param locks each PathLock that must be acquired
* @throws InterruptedException
*/
private AcquiredMultiPathLock(final List<ActivePath.PathScopedLock> locks) throws InterruptedException {
this.locks = locks;
boolean success = false;
while (!success) {
synchronized (DefaultPathLockManager.this) {
success = tryAcquireAll();
if (!success) {
LOGGER.debug("Failed to acquire all necessary path locks: waiting. (Thread {})",
Thread.currentThread().getId());
DefaultPathLockManager.this.wait();
}
}
}
LOGGER.debug("Acquired all necessary path locks (Thread {})", Thread.currentThread().getId());
}
/**
* Instantiates and initializes an AcquiredMultiPathLock that requires
* locks on the given path and all active paths that are descendants of
* the given path. This constructor blocks until all of the necessary
* locks have been acquired, but to avoid possible deadlocks releases
* all acquired locks when it fails to acquire even one of them.
* @param deletePath the path for which all descendant paths must also be
* write locked.
* @throws InterruptedException
*/
private AcquiredMultiPathLock(final String deletePath) throws InterruptedException {
this.deletePath = deletePath;
boolean success = false;
while (!success) {
synchronized (DefaultPathLockManager.this) {
this.locks = new ArrayList<>();
// find all paths to lock
activePaths.forEach((path, lock) -> {
if (isOrIsDescendantOf(path, deletePath)) {
locks.add(lock.getWriteLock());
}
});
success = tryAcquireAll();
if (!success) {
LOGGER.debug("Failed to acquire all necessary path locks: waiting. (Thread {})",
Thread.currentThread().getId());
DefaultPathLockManager.this.wait();
} else {
// So, we have acquired locks on every currently active path that is
// the target of the DELETE operation or its ancestor... but what if
// one is added once we fall out of this synchronized block?
//
// ...well, in that case we set a special note that this path is being
// deleted so that whenever a new lock is attempted on a to-be-deleted
// path, those locks fail to acquire.
LOGGER.trace("Thread {} acquired delete lock on path {}.",
Thread.currentThread().getId(), deletePath);
activeDeletePaths.add(deletePath);
}
}
}
LOGGER.debug("Acquired all necessary path locks (Thread {})", Thread.currentThread().getId());
}
private boolean tryAcquireAll() {
final List<ActivePath.PathScopedLock> acquired = new ArrayList<>();
for (final ActivePath.PathScopedLock lock : locks) {
if (lock.tryLock()) {
acquired.add(lock);
} else {
// roll back
acquired.forEach(ActivePath.PathScopedLock::unlock);
return false;
}
}
return true;
}
/*
* This is the only method that removes paths from the pool
* of currently active paths that can be locked.
*/
@Override
public void release() {
synchronized (DefaultPathLockManager.this) {
for (final ActivePath.PathScopedLock lock : locks) {
lock.unlock();
lock.getPath().threads.remove(Thread.currentThread());
if (lock.getPath().threads.isEmpty()) {
activePaths.remove(lock.getPath().path);
}
}
if (deletePath != null) {
LOGGER.trace("Thread {} releasing delete lock on path {}.",
Thread.currentThread().getId(), deletePath);
activeDeletePaths.remove(deletePath);
}
LOGGER.trace("Thread {} released locks.", Thread.currentThread().getId());
DefaultPathLockManager.this.notify();
}
}
}
/*
* This is the only method that adds paths to the pool of
* currently active paths that can be locked.
*/
private synchronized ActivePath getActivePath(final String path) {
ActivePath activePath = activePaths.get(path);
if (activePath == null) {
activePath = new ActivePath(path);
activePaths.put(path, activePath);
}
return activePath;
}
private boolean isOrIsDescendantOf(final String possibleDescendant, final String path) {
return path.equals(possibleDescendant) || possibleDescendant.startsWith(path + "/");
}
private String getParentPath(final String path) {
if (path.indexOf('/') == -1) {
return null;
}
return path.substring(0, path.lastIndexOf('/'));
}
@VisibleForTesting
static String normalizePath(final String path) {
if (path.endsWith("/")) {
return path.substring(0, path.length() - 1);
} else {
return path;
}
}
@Override
public AcquiredLock lockForRead(final String path) {
final List<ActivePath.PathScopedLock> locks = new ArrayList<>();
synchronized (this) {
locks.add(getActivePath(normalizePath(path)).getReadLock());
}
try {
return new AcquiredMultiPathLock(locks);
} catch (InterruptedException e) {
throw new InterruptedRuntimeException(e);
}
}
@Override
public AcquiredLock lockForWrite(final String path, final FedoraSession session, final NodeService nodeService) {
final List<ActivePath.PathScopedLock> locks = new ArrayList<>();
synchronized (this) {
// lock the specified path while iterating through the path's
// ancestry to also lock each path that would be created implicitly
// by this write (ie, non-existent ancestral paths)
final String startingPath = normalizePath(path);
for (String currentPath = startingPath ;
currentPath == null || currentPath.length() > 0;
currentPath = getParentPath(currentPath)) {
if (currentPath == null || (currentPath != startingPath && nodeService.exists(session, currentPath))) {
// either we've followed the path back to the root, or we've found an ancestor that exists...
// so there are no more locks to create.
break;
}
locks.add(getActivePath(currentPath).getWriteLock());
}
}
try {
return new AcquiredMultiPathLock(locks);
} catch (InterruptedException e) {
throw new InterruptedRuntimeException(e);
}
}
@Override
public AcquiredLock lockForDelete(final String path) {
try {
return new AcquiredMultiPathLock(normalizePath(path));
} catch (InterruptedException e) {
throw new InterruptedRuntimeException(e);
}
}
}