blob: 6588860a20b12a81897b99667f128527460aa600 [file] [log] [blame]
// Copyright 2021 The Bazel Authors. All rights reserved.
//
// 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.google.devtools.build.lib.skyframe;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.util.concurrent.Futures;
import com.google.devtools.build.lib.actions.FileStateType;
import com.google.devtools.build.lib.actions.FileStateValue;
import com.google.devtools.build.lib.concurrent.ExecutorUtil;
import com.google.devtools.build.lib.server.FailureDetails;
import com.google.devtools.build.lib.server.FailureDetails.DiffAwareness.Code;
import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
import com.google.devtools.build.lib.util.AbruptExitException;
import com.google.devtools.build.lib.util.DetailedExitCode;
import com.google.devtools.build.lib.util.io.TimestampGranularityMonitor;
import com.google.devtools.build.lib.vfs.Dirent;
import com.google.devtools.build.lib.vfs.FileStateKey;
import com.google.devtools.build.lib.vfs.RootedPath;
import com.google.devtools.build.lib.vfs.SyscallCache;
import com.google.devtools.build.skyframe.Differencer.DiffWithDelta.Delta;
import com.google.devtools.build.skyframe.ImmutableDiff;
import com.google.devtools.build.skyframe.InMemoryGraph;
import com.google.devtools.build.skyframe.InMemoryNodeEntry;
import com.google.devtools.build.skyframe.SkyKey;
import com.google.devtools.build.skyframe.Version;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import javax.annotation.Nullable;
/**
* A helper class to find dirty {@link FileStateValue} and {@link DirectoryListingStateValue} nodes
* based on a potentially incomplete diffs.
*
* <p>Infers directories from files meaning that it will work for diffs which exclude entries for
* affected ancestor entries of nodes. It is also resilient to diffs which report only a root of
* deleted subtree.
*/
public final class FileSystemValueCheckerInferringAncestors {
@Nullable private final TimestampGranularityMonitor tsgm;
private final InMemoryGraph inMemoryGraph;
private final Map<RootedPath, NodeVisitState> nodeStates;
private final SyscallCache syscallCache;
private final SkyValueDirtinessChecker skyValueDirtinessChecker;
private final Set<SkyKey> valuesToInvalidate = Sets.newConcurrentHashSet();
private final ConcurrentMap<SkyKey, Delta> valuesToInject = new ConcurrentHashMap<>();
private static final class NodeVisitState {
private NodeVisitState(boolean collectMaybeDeletedChildren) {
if (collectMaybeDeletedChildren) {
maybeDeletedChildren = ConcurrentHashMap.newKeySet();
}
}
private final AtomicInteger childrenToProcess = new AtomicInteger();
// non-volatile since childrenToProcess ensures happens-before relationship.
private boolean needsToBeVisited;
private volatile boolean isInferredDirectory;
private volatile Set<String> maybeDeletedChildren;
void markInferredDirectory() {
isInferredDirectory = true;
// maybeTypeChangedChildren is used to figure out if the entry is a directory, since we
// already inferred it, we can stop collecting those.
maybeDeletedChildren = null;
}
void addMaybeDeletedChild(String child) {
Set<String> localMaybeDeletedChildren = maybeDeletedChildren;
if (localMaybeDeletedChildren != null) {
localMaybeDeletedChildren.add(child);
}
}
boolean signalFinishedChild(boolean needsToBeVisited) {
// The order is important, we must update this.needsToBeVisited before decrementing
// childrenToProcess -- that operation ensures this change is visible to other threads doing
// the same (including this thread picking up a true set by another one).
if (needsToBeVisited) {
this.needsToBeVisited = true;
}
int childrenLeft = childrenToProcess.decrementAndGet();
// If we hit 0, we know that all other threads have set and propagated needsToBeVisited.
return childrenLeft == 0 && this.needsToBeVisited;
}
}
private FileSystemValueCheckerInferringAncestors(
@Nullable TimestampGranularityMonitor tsgm,
InMemoryGraph inMemoryGraph,
Map<RootedPath, NodeVisitState> nodeStates,
SyscallCache syscallCache,
SkyValueDirtinessChecker skyValueDirtinessChecker) {
this.tsgm = tsgm;
this.nodeStates = nodeStates;
this.syscallCache = syscallCache;
this.skyValueDirtinessChecker = skyValueDirtinessChecker;
this.inMemoryGraph = inMemoryGraph;
}
@VisibleForTesting
@SuppressWarnings("ReferenceEquality")
public static ImmutableDiff getDiffWithInferredAncestors(
@Nullable TimestampGranularityMonitor tsgm,
InMemoryGraph inMemoryGraph,
Iterable<FileStateKey> modifiedKeys,
int nThreads,
SyscallCache syscallCache,
SkyValueDirtinessChecker skyValueDirtinessChecker)
throws InterruptedException, AbruptExitException {
Map<RootedPath, NodeVisitState> nodeStates = makeNodeVisitStates(modifiedKeys);
return new FileSystemValueCheckerInferringAncestors(
tsgm,
inMemoryGraph,
Collections.unmodifiableMap(nodeStates),
syscallCache,
skyValueDirtinessChecker)
.processEntries(nThreads);
}
private static Map<RootedPath, NodeVisitState> makeNodeVisitStates(
Iterable<FileStateKey> modifiedKeys) {
Map<RootedPath, NodeVisitState> nodeStates = new HashMap<>();
for (FileStateKey fileStateKey : modifiedKeys) {
RootedPath top = fileStateKey.argument();
// Start with false since the reported diff does not mean we are adding a child.
boolean lastCreated = false;
for (RootedPath path = top; path != null; path = path.getParentDirectory()) {
@Nullable NodeVisitState existingState = nodeStates.get(path);
NodeVisitState state;
// We disable the optimization which detects whether directory still exists based on the
// list of deleted children and listing. It is possible for the diff to report a deleted
// directory without listing all of the files under it as deleted.
if (existingState == null) {
state = new NodeVisitState(/*collectMaybeDeletedChildren=*/ path != top);
nodeStates.put(path, state);
} else {
state = existingState;
if (path == top) {
state.maybeDeletedChildren = null;
}
}
if (lastCreated) {
state.childrenToProcess.incrementAndGet();
}
lastCreated = existingState == null;
}
}
return nodeStates;
}
private ImmutableDiff processEntries(int nThreads)
throws InterruptedException, AbruptExitException {
ExecutorService executor = Executors.newFixedThreadPool(nThreads);
// Materialize all leaves before scheduling them -- otherwise, we could race with the
// processing code which decrements childrenToProcess.
ImmutableList<Callable<Void>> leaves =
nodeStates.entrySet().stream()
.filter(e -> e.getValue().childrenToProcess.get() == 0)
.<Callable<Void>>map(
e ->
() -> {
processEntry(e.getKey(), e.getValue());
return null;
})
.collect(toImmutableList());
List<Future<Void>> futures = executor.invokeAll(leaves);
if (ExecutorUtil.interruptibleShutdown(executor)) {
throw new InterruptedException();
}
for (Future<?> future : futures) {
try {
Futures.getDone(future);
} catch (ExecutionException e) {
if (e.getCause() instanceof StatFailedException) {
throw new AbruptExitException(
DetailedExitCode.of(
FailureDetail.newBuilder()
.setMessage(e.getCause().getMessage())
.setDiffAwareness(
FailureDetails.DiffAwareness.newBuilder().setCode(Code.DIFF_STAT_FAILED))
.build()),
e);
}
throw new IllegalStateException(e);
}
}
return new ImmutableDiff(valuesToInvalidate, valuesToInject);
}
private void processEntry(RootedPath path, NodeVisitState state) throws StatFailedException {
NodeVisitState rootParentSentinel = new NodeVisitState(/*collectMaybeDeletedChildren=*/ false);
while (state != rootParentSentinel) {
RootedPath parentPath = path.getParentDirectory();
NodeVisitState parentState =
parentPath != null ? nodeStates.get(parentPath) : rootParentSentinel;
boolean visitParent =
visitEntry(path, state.isInferredDirectory, state.maybeDeletedChildren, parentState);
boolean processParent = parentState.signalFinishedChild(visitParent);
if (!processParent) {
// This is a tree, only one child can trigger parent processing.
return;
}
state = parentState;
path = path.getParentDirectory();
}
}
/**
* Visits the given node and return whether the type of it may have changed.
*
* <p>Returns false if we know that the type has not changed. It may however return true if the
* type has not changed.
*
* @param isInferredDirectory whether the node was already inferred as a directory from children.
* @param maybeDeletedChildren if not null, exhaustive list of all children which may have their
* file system type changed (including deletions).
*/
private boolean visitEntry(
RootedPath path,
boolean isInferredDirectory,
@Nullable Set<String> maybeDeletedChildren,
NodeVisitState parentState)
throws StatFailedException {
FileStateKey key = FileStateValue.key(path);
@Nullable InMemoryNodeEntry fsvNode = inMemoryGraph.getIfPresent(key);
@Nullable FileStateValue oldFsv = fsvNode != null ? (FileStateValue) fsvNode.toValue() : null;
if (oldFsv == null) {
visitUnknownEntry(key, isInferredDirectory, parentState);
parentState.addMaybeDeletedChild(path.getRootRelativePath().getBaseName());
return true;
}
if (isInferredDirectory
|| (maybeDeletedChildren != null
&& listingHasEntriesOutsideOf(path, maybeDeletedChildren))) {
parentState.markInferredDirectory();
if (oldFsv.getType().isDirectory()) {
return false;
}
Version directoryFileStateNodeMtsv =
skyValueDirtinessChecker.getMaxTransitiveSourceVersionForNewValue(
key, FileStateValue.DIRECTORY_FILE_STATE_NODE);
valuesToInject.put(
key, Delta.justNew(FileStateValue.DIRECTORY_FILE_STATE_NODE, directoryFileStateNodeMtsv));
parentListingKey(path).ifPresent(valuesToInvalidate::add);
return true;
}
@Nullable FileStateValue newFsv = injectAndGetNewFileStateValueIfDirty(path, fsvNode, oldFsv);
if (newFsv.getType().exists()) {
parentState.markInferredDirectory();
} else if (oldFsv.getType().exists()) {
// exists -> not exists -- deletion.
parentState.addMaybeDeletedChild(path.getRootRelativePath().getBaseName());
}
boolean typeChanged = newFsv.getType() != oldFsv.getType();
if (typeChanged) {
parentListingKey(path).ifPresent(valuesToInvalidate::add);
}
return typeChanged;
}
/**
* Injects the new file state value if dirty. Returns the old file state value if not dirty and
* the new file state value if dirty.
*/
private FileStateValue injectAndGetNewFileStateValueIfDirty(
RootedPath path, InMemoryNodeEntry oldFsvNode, FileStateValue oldFsv)
throws StatFailedException {
Preconditions.checkState(oldFsv != null, "Unexpected null FileStateValue.");
@Nullable Version oldMtsv = oldFsvNode.getMaxTransitiveSourceVersion();
SkyValueDirtinessChecker.DirtyResult dirtyResult =
skyValueDirtinessChecker.check(oldFsvNode.getKey(), oldFsv, oldMtsv, syscallCache, tsgm);
if (!dirtyResult.isDirty()) {
return oldFsv;
}
@Nullable FileStateValue newFsv = (FileStateValue) dirtyResult.getNewValue();
if (newFsv == null) {
throw new StatFailedException(path, new IOException("Filesystem access failed."));
}
@Nullable Version newMtsv = dirtyResult.getNewMaxTransitiveSourceVersion();
if (newMtsv == null && !skyValueDirtinessChecker.nullMaxTransitiveSourceVersionOk()) {
throw new StatFailedException(path, new IOException("Filesystem access failed."));
}
valuesToInject.put(oldFsvNode.getKey(), Delta.justNew(newFsv, newMtsv));
return newFsv;
}
private void visitUnknownEntry(
FileStateKey key, boolean isInferredDirectory, NodeVisitState parentState)
throws StatFailedException {
RootedPath path = key.argument();
// Run stats on unknown files in order to preserve the parent listing if present unless we
// already know it has changed.
Optional<DirectoryListingStateValue.Key> parentListingKey = parentListingKey(path);
@Nullable DirectoryListingStateValue parentListing = null;
if (parentListingKey.isPresent()) {
@Nullable InMemoryNodeEntry entry = inMemoryGraph.getIfPresent(parentListingKey.get());
parentListing =
entry != null && entry.isDone() ? (DirectoryListingStateValue) entry.getValue() : null;
}
// No listing/we already know it has changed -- nothing to gain from stats anymore.
if (parentListing == null || valuesToInvalidate.contains(parentListingKey.get())) {
if (isInferredDirectory) {
parentState.markInferredDirectory();
}
valuesToInvalidate.add(key);
parentListingKey.ifPresent(valuesToInvalidate::add);
return;
}
// We don't take advantage of isInferredDirectory because we set it only in cases of a present
// descendant/done listing which normally cannot exist without having FileStateValue for
// ancestors.
@Nullable FileStateValue newValue = injectAndGetNewFileStateValueForUnknownEntry(path, key);
if (isInferredDirectory || newValue.getType().exists()) {
parentState.markInferredDirectory();
}
@Nullable
Dirent dirent =
parentListing.getDirents().maybeGetDirent(path.getRootRelativePath().getBaseName());
@Nullable Dirent.Type typeInListing = dirent != null ? dirent.getType() : null;
if (!Objects.equals(typeInListing, direntTypeFromFileStateType(newValue.getType()))) {
valuesToInvalidate.add(parentListingKey.get());
}
}
/** Injects the new file state value for unknown entry. */
private FileStateValue injectAndGetNewFileStateValueForUnknownEntry(RootedPath path, SkyKey key)
throws StatFailedException {
@Nullable
FileStateValue newValue =
(FileStateValue) skyValueDirtinessChecker.createNewValue(path, syscallCache, tsgm);
if (newValue == null) {
throw new StatFailedException(path, new IOException("Filesystem access failed."));
}
Version newMtsv =
skyValueDirtinessChecker.getMaxTransitiveSourceVersionForNewValue(key, newValue);
if (newMtsv == null && !skyValueDirtinessChecker.nullMaxTransitiveSourceVersionOk()) {
throw new StatFailedException(path, new IOException("Filesystem access failed."));
}
valuesToInject.put(key, Delta.justNew(newValue, newMtsv));
return newValue;
}
private boolean listingHasEntriesOutsideOf(RootedPath path, Set<String> allAffectedEntries) {
// TODO(192010830): Try looking up BUILD files if there is no listing -- this is a lookup we
// can speculatively try since those files are often checked against.
@Nullable
InMemoryNodeEntry nodeEntry = inMemoryGraph.getIfPresent(DirectoryListingStateValue.key(path));
@Nullable
DirectoryListingStateValue listing =
nodeEntry != null && nodeEntry.isDone()
? (DirectoryListingStateValue) nodeEntry.getValue()
: null;
if (listing == null) {
return false;
}
for (Dirent entry : listing.getDirents()) {
if (!allAffectedEntries.contains(entry.getName())) {
return true;
}
}
return false;
}
private static Optional<DirectoryListingStateValue.Key> parentListingKey(RootedPath path) {
return Optional.ofNullable(path.getParentDirectory()).map(DirectoryListingStateValue::key);
}
@Nullable
private static Dirent.Type direntTypeFromFileStateType(FileStateType type) {
switch (type) {
case NONEXISTENT:
return null;
case REGULAR_FILE:
return Dirent.Type.FILE;
case SPECIAL_FILE:
return Dirent.Type.UNKNOWN;
case SYMLINK:
return Dirent.Type.SYMLINK;
case DIRECTORY:
return Dirent.Type.DIRECTORY;
}
throw new AssertionError();
}
private static class StatFailedException extends Exception {
StatFailedException(RootedPath path, IOException cause) {
super(String.format("Failed to stat: '%s' while computing diff", path.asPath()), cause);
}
}
}