/*
* Copyright 2017-present Facebook, Inc.
*
* 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 com.facebook.buck.util;
import com.facebook.buck.util.concurrent.AutoCloseableLock;
import com.facebook.buck.util.concurrent.AutoCloseableReadWriteUpdateLock;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
import javax.annotation.Nullable;
/**
* This class implements a map for a filesystem structure relying on a prefix tree. The trie only
* supports relative paths for now, but an effort can be made in order to let it support absolute
* paths if needed. Every intermediate or leaf node of the trie is a folder/file which may be
* associated with a value. It's worth noting that adding a value for path foo/bar/file.txt will add
* intermediate nodes for foo and foo/bar but not values. The value is associated with the specified
* path and not with its ancestors. Invalidating one of the leaves or intermediate nodes will cause
* its parent and its ancestors to be invalidated as well: this operation consists in removing the
* leaf from its parent children and setting the value of all its ancestors to null. If the removal
* of the target leaf leaves an empty branch (a stump), that is removed as well in order to keep the
* prefix tree as slim as possible.
*
* <p>This class is thread safe in its public methods: concurrent calls to the trie will have the
* exclusiveness in write/remove operations, while allowing parallel reads to the whole data
* structure: an attempt could be made to make the trie more concurrent by locking branches of the
* trie instead of the whole object, although that will require some thought around how to grant
* parallel writes.
*
* @param <T> The type to associate with a specific path.
*/
public class FileSystemMap<T> {
/**
* Wrapper class that implements a method for loading a value in the leaves of the trie.
*
* @param <T>
*/
public interface ValueLoader<T> {
T load(Path path);
}
/**
* Entry is the class representing a file/folder in the prefix tree. Its main responsibilities are
* to fetch a child of the current folder or the current value, if the entry is a leaf or "inner
* leaf" - that is an internal node associated with a value.
*
* @param <T> The type of the contained value.
*/
@VisibleForTesting
static class Entry<T> {
Map<Path, Entry<T>> subLevels = new HashMap<>();
// The value of the Entry is the actual value the node is associated with:
// - If this is a leaf node, value is never null.
// - If this is an intermediate node, value is not `null` *only* if we already received a
// get() or put() on its path: in this case, its value will be computed and stored.
// Otherwise, the node exists only as a mean to reach its children/leaves and will have
// a `null` value.
@Nullable T value;
Entry() {
// We're creating an empty node here, so it is associated with no value.
this.value = null;
}
Entry(@Nullable T value) {
this.value = value;
}
int size() {
return subLevels.size();
}
}
@VisibleForTesting final Entry<T> root = new Entry<>();
private final ValueLoader<T> loader;
private final AutoCloseableReadWriteUpdateLock lock = new AutoCloseableReadWriteUpdateLock();
public FileSystemMap(ValueLoader<T> loader) {
this.loader = loader;
}
/**
* Puts a path and a value into the map.
*
* @param path The path to store.
* @param value The value to associate to the given path.
* @return The entry just created.
*/
public T put(Path path, T value) {
try (AutoCloseableLock updateLock = lock.updateLock()) {
Entry<T> entry = putEntry(path);
try (AutoCloseableLock writeLock = lock.writeLock()) {
entry.value = value;
}
return entry.value;
}
}
// Creates the intermediate (and/or the leaf node) if needed and returns the leaf associated
// with the given path.
// Must be called while owning an updatable lock.
private Entry<T> putEntry(Path path) {
Entry<T> parent = root;
for (Path p : path) {
// Create the intermediate node only if it's missing.
if (!parent.subLevels.containsKey(p)) {
try (AutoCloseableLock writeLock = lock.writeLock()) {
if (!parent.subLevels.containsKey(p)) {
Entry<T> newEntry = new Entry<>();
parent.subLevels.put(p, newEntry);
}
}
}
parent = parent.subLevels.get(p);
// parent should never be null.
Preconditions.checkNotNull(parent);
}
return parent;
}
/**
* Removes the given path.
*
* <p>Removing a path involves the following: - all the child nodes of the given path are
* discarded as well. - all the paths upstream lose their value. - each path upstream will be
* removed if after the removal of the leaf the node is left with no children (that is, it has
* become a stump).
*
* @param path The path specifying the branch to remove.
*/
public void remove(Path path) {
try (AutoCloseableLock updateLock = lock.updateLock()) {
Stack<Entry<T>> stack = new Stack<>();
stack.push(root);
Entry<T> entry = root;
// Walk the tree to fetch the node requested by the path, or the closest intermediate node.
for (Path p : path) {
entry = entry.subLevels.get(p);
// We're trying to remove a path that doesn't exist, no point in going deeper.
// Break and proceed to remove whatever path we found so far.
if (entry == null) {
break;
}
stack.push(entry);
}
// The following approach supports these cases:
// 1. Remove a path that has been found as a leaf in the trie (easy case).
// 2. If the path does't exist at the root level, then don't even bother removing anything.
// 3. We still want to remove paths that "exist partially", that is we haven't found the
// requested leaf, but we have found an intermediate node on the branch.
// 4. Similarly, we want to support prefix removal as well (i.e.: if we want to remove an
// intermediate node).
if (stack.size() > 1) { // check the size in order to address for case #2.
// Let's take the actual (sub)path we're removing, by using the size of the stack (ignoring
// the root).
path = path.subpath(0, stack.size() - 1);
// If we reached the leaf, then remove the leaf and everything below it (if any).
stack.pop();
try (AutoCloseableLock writeLock = lock.writeLock()) {
stack.peek().subLevels.remove(path.getFileName());
// Plus, check everything above in order to remove unused stumps.
while (!stack.empty()) {
// This will never throw NPE because if it does, then the stack was empty at the beginning
// of the iteration (we went upper than the root node, which doesn't make sense).
path = path.getParent();
Entry<T> current = stack.pop();
if (current.size() == 0 && path != null && !stack.empty()) {
stack.peek().subLevels.remove(path.getFileName());
} else {
current.value = null;
}
}
}
}
}
}
/** Empties the trie leaving only the root node available. */
public void removeAll() {
try (AutoCloseableLock writeLock = lock.writeLock()) {
root.subLevels = new HashMap<>();
}
}
/**
* Gets the value associated with the given path.
*
* @param path The path to fetch.
* @return The value associated with the path.
*/
public T get(Path path) throws IOException {
try (AutoCloseableLock updateLock = lock.updateLock()) {
Entry<T> entry = putEntry(path);
// Maybe here we receive a request for getting an intermediate node (a folder) whose
// value was never computed before (or has been removed).
if (entry.value == null) {
try (AutoCloseableLock writeLock = lock.writeLock()) {
// Check if while we obtained the lock some other thread modified the value we want.
if (entry.value == null) {
entry.value = loader.load(path);
}
}
}
return entry.value;
}
}
}