blob: 044bbce9b28e8aafa15946a728317a3a9838acfe [file]
// Copyright 2026 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.remote;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.devtools.build.lib.actions.Action;
import com.google.devtools.build.lib.actions.ActionLookupData;
import com.google.devtools.build.lib.actions.ActionLookupKey;
import com.google.devtools.build.lib.actions.ActionLookupValue;
import com.google.devtools.build.lib.actions.ActionTemplate;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.actions.Artifact.DerivedArtifact;
import com.google.devtools.build.lib.actions.Artifact.SpecialArtifact;
import com.google.devtools.build.lib.actions.Artifact.SpecialArtifactType;
import com.google.devtools.build.lib.actions.Artifact.TreeFileArtifact;
import com.google.devtools.build.lib.actions.ArtifactRoot;
import com.google.devtools.build.lib.actions.ArtifactRoot.RootType;
import com.google.devtools.build.lib.actions.FileArtifactValue;
import com.google.devtools.build.lib.actions.InputMetadataProvider;
import com.google.devtools.build.lib.actions.RunfilesArtifactValue;
import com.google.devtools.build.lib.actions.RunfilesTree;
import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
import com.google.devtools.build.lib.collect.nestedset.Order;
import com.google.devtools.build.lib.profiler.SilentCloseable;
import com.google.devtools.build.lib.skyframe.ActionTemplateExpansionValue;
import com.google.devtools.build.lib.skyframe.ActionTemplateExpansionValue.ActionTemplateExpansionKey;
import com.google.devtools.build.lib.testutil.TestThread;
import com.google.devtools.build.lib.vfs.DigestHashFunction;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
import com.google.devtools.build.skyframe.WalkableGraph;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.InOrder;
/** Tests for {@link RemoteRewoundActionSynchronizer}. */
@RunWith(JUnit4.class)
public final class RemoteRewoundActionSynchronizerTest {
private static final long DEADLOCK_TIMEOUT_MILLIS = 10_000;
private RemoteActionInputFetcher actionInputFetcher;
private WalkableGraph graph;
private RemoteRewoundActionSynchronizer synchronizer;
@Before
public void setUp() {
actionInputFetcher = mock(RemoteActionInputFetcher.class);
graph = mock(WalkableGraph.class);
synchronizer = new RemoteRewoundActionSynchronizer(actionInputFetcher, graph);
}
@Test
public void rewind_cancelsAllRegisteredTasksOnce() throws Exception {
Action action = newAction();
var first = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
var second = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
var unusedFirst = synchronizer.registerOutputUploadTask(action, first);
var unusedSecond = synchronizer.registerOutputUploadTask(action, second);
rewind(action);
rewind(action);
verify(first, times(1)).requestCancellation();
verify(first, times(1)).awaitCompletion();
verify(second, times(1)).requestCancellation();
verify(second, times(1)).awaitCompletion();
}
@Test
public void rewind_interruptedWhileAwaiting_cancelsAndAwaitsEveryTask() throws Exception {
Action action = newAction();
var first = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
var second = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
doThrow(new InterruptedException()).doNothing().when(first).awaitCompletion();
var unusedFirst = synchronizer.registerOutputUploadTask(action, first);
var unusedSecond = synchronizer.registerOutputUploadTask(action, second);
assertThrows(InterruptedException.class, () -> rewind(action));
InOrder order = inOrder(first, second);
order.verify(first).requestCancellation();
order.verify(second).requestCancellation();
order.verify(first, times(2)).awaitCompletion();
order.verify(second).awaitCompletion();
verify(actionInputFetcher, never()).handleRewoundActionOutputs(any());
}
@Test
public void unregisterHandle_preventsCancellation() throws Exception {
Action action = newAction();
var task = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
Runnable unregister = synchronizer.registerOutputUploadTask(action, task);
unregister.run();
rewind(action);
verify(task, never()).requestCancellation();
verify(task, never()).awaitCompletion();
assertThat(synchronizer.hasRegisteredOutputUploadTasks(action)).isFalse();
}
@Test
public void predecessorUnregisterHandle_doesNotRemoveReplacementTask() throws Exception {
Action action = newAction();
var predecessor = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
Runnable unregisterPredecessor = synchronizer.registerOutputUploadTask(action, predecessor);
rewind(action);
var replacement = mock(RemoteRewoundActionSynchronizer.Cancellable.class);
var unused = synchronizer.registerOutputUploadTask(action, replacement);
unregisterPredecessor.run();
rewind(action);
verify(predecessor, times(1)).requestCancellation();
verify(predecessor, times(1)).awaitCompletion();
verify(replacement, times(1)).requestCancellation();
verify(replacement, times(1)).awaitCompletion();
}
@Test
public void unregisterHandle_usesIdentityComparison() throws Exception {
Action action = newAction();
var first = new EqualCancellable();
var second = new EqualCancellable();
Runnable unregisterFirst = synchronizer.registerOutputUploadTask(action, first);
var unused = synchronizer.registerOutputUploadTask(action, second);
unregisterFirst.run();
rewind(action);
assertThat(first.cancellations.get()).isEqualTo(0);
assertThat(second.cancellations.get()).isEqualTo(1);
}
/**
* A runfiles tree is guarded by the keys of the actions generating the artifacts it contains,
* which are taken from its metadata rather than from all runfiles trees known to the metadata
* provider. The action generating the runfiles tree itself isn't excluded, as it doesn't write to
* disk.
*/
@Test
public void outputProcessing_runfilesTree_locksOnlyItsProducers() throws Exception {
FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
ArtifactRoot root = ArtifactRoot.asDerivedRoot(fs.getPath("/exec"), RootType.OUTPUT, "out");
var owner = ActionsTestUtil.NULL_ARTIFACT_OWNER;
DerivedArtifact file = (DerivedArtifact) ActionsTestUtil.createArtifact(root, "file");
file.setGeneratingActionKey(ActionLookupData.create(owner, 0));
Action producer = newAction(ImmutableList.of(file), ImmutableList.of());
DerivedArtifact unrelatedFile =
(DerivedArtifact) ActionsTestUtil.createArtifact(root, "unrelated");
unrelatedFile.setGeneratingActionKey(ActionLookupData.create(owner, 1));
Action unrelatedProducer = newAction(ImmutableList.of(unrelatedFile), ImmutableList.of());
SpecialArtifact runfiles = ActionsTestUtil.createRunfilesArtifact(root, "out/runfiles");
runfiles.setGeneratingActionKey(ActionLookupData.create(owner, 2));
Action runfilesAction = newAction(ImmutableList.of(runfiles), ImmutableList.of(file));
RunfilesTree runfilesTree = mock(RunfilesTree.class);
when(runfilesTree.getArtifacts()).thenReturn(NestedSetBuilder.create(Order.STABLE_ORDER, file));
RunfilesTree unrelatedRunfilesTree = mock(RunfilesTree.class);
when(unrelatedRunfilesTree.getArtifacts())
.thenReturn(NestedSetBuilder.create(Order.STABLE_ORDER, unrelatedFile));
InputMetadataProvider metadataProvider = mock(InputMetadataProvider.class);
when(metadataProvider.getRunfilesTrees())
.thenReturn(ImmutableList.of(runfilesTree, unrelatedRunfilesTree));
var runfilesValue =
new RunfilesArtifactValue(
runfilesTree,
ImmutableList.of(file),
ImmutableList.of(FileArtifactValue.createForNormalFile(new byte[32], null, 0)),
ImmutableList.of(),
ImmutableList.of(),
ImmutableList.of(),
ImmutableList.of());
when(metadataProvider.getRunfilesMetadata(runfiles)).thenReturn(runfilesValue);
// Switch to the fine locks with an unrelated rewound action first: the coarse lock would
// exclude every rewound action regardless of which keys guard the runfiles tree.
rewind(newAction());
var preparation = new TestThread(() -> rewind(producer));
var unrelatedPreparation = new TestThread(() -> rewind(unrelatedProducer));
var runfilesPreparation = new TestThread(() -> rewind(runfilesAction));
try (SilentCloseable processing =
synchronizer.enterProcessOutputsAndGetLostArtifacts(
ImmutableList.of(runfiles), metadataProvider)) {
preparation.start();
waitUntilBlocked(preparation);
unrelatedPreparation.start();
unrelatedPreparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
runfilesPreparation.start();
runfilesPreparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
}
preparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
}
/**
* A consumer of a tree artifact declared by an {@link ActionTemplate} excludes the rewinding of
* every expanded action populating it, including one whose only output in the tree is an empty
* subdirectory and which thus doesn't generate any of the tree's files.
*/
@Test
public void expandedActionRewound_emptySubdirectoryProducer_waitsForTreeConsumer()
throws Exception {
FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
ArtifactRoot root = ArtifactRoot.asDerivedRoot(fs.getPath("/exec"), RootType.OUTPUT, "out");
var owner = ActionsTestUtil.NULL_ARTIFACT_OWNER;
SpecialArtifact tree = newTreeArtifact(root, "tree", ActionLookupData.create(owner, 0));
ActionTemplateExpansionKey expansion = ActionTemplateExpansionValue.key(owner, 0);
SpecialArtifact subdirectory =
SpecialArtifact.createSubTreeArtifact(tree, PathFragment.create("empty"), expansion);
subdirectory.setGeneratingActionKey(ActionLookupData.create(expansion, 0));
Action producer = newAction(ImmutableList.of(subdirectory), ImmutableList.of());
DerivedArtifact consumerOutput =
(DerivedArtifact) ActionsTestUtil.createArtifact(root, "consumer.out");
consumerOutput.setGeneratingActionKey(ActionLookupData.create(owner, 1));
Action consumer = newAction(ImmutableList.of(consumerOutput), ImmutableList.of(tree));
InputMetadataProvider metadataProvider = mock(InputMetadataProvider.class);
mockTemplateExpansion(owner, ImmutableList.of(producer));
// Switch to the fine locks with an unrelated rewound action first: the coarse lock would
// exclude the producer regardless of which keys guard the tree.
rewind(newAction());
var preparation = new TestThread(() -> rewind(producer));
try (SilentCloseable execution =
synchronizer.enterActionExecution(consumer, /* wasRewound= */ false, metadataProvider)) {
preparation.start();
waitUntilBlocked(preparation);
}
preparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
}
/**
* A consumer of one output tree of an {@link ActionTemplate} doesn't depend on expanded actions
* that only populate another output tree of the same template and thus must not wait for their
* rewinding, whereas it does exclude the rewinding of the actions populating its own tree.
*/
@Test
public void expandedActionRewound_treeConsumer_excludesOnlyProducersOfItsTree() throws Exception {
FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
ArtifactRoot root = ArtifactRoot.asDerivedRoot(fs.getPath("/exec"), RootType.OUTPUT, "out");
var owner = ActionsTestUtil.NULL_ARTIFACT_OWNER;
var templateKey = ActionLookupData.create(owner, 0);
ActionTemplateExpansionKey expansion = ActionTemplateExpansionValue.key(owner, 0);
SpecialArtifact firstTree = newTreeArtifact(root, "first", templateKey);
SpecialArtifact secondTree = newTreeArtifact(root, "second", templateKey);
TreeFileArtifact firstFile =
TreeFileArtifact.createTemplateExpansionOutput(firstTree, "file", expansion);
firstFile.setGeneratingActionKey(ActionLookupData.create(expansion, 0));
TreeFileArtifact secondFile =
TreeFileArtifact.createTemplateExpansionOutput(secondTree, "file", expansion);
secondFile.setGeneratingActionKey(ActionLookupData.create(expansion, 1));
Action firstProducer = newAction(ImmutableList.of(firstFile), ImmutableList.of());
Action secondProducer = newAction(ImmutableList.of(secondFile), ImmutableList.of());
DerivedArtifact consumerOutput =
(DerivedArtifact) ActionsTestUtil.createArtifact(root, "consumer.out");
consumerOutput.setGeneratingActionKey(ActionLookupData.create(owner, 1));
Action consumer = newAction(ImmutableList.of(consumerOutput), ImmutableList.of(firstTree));
InputMetadataProvider metadataProvider = mock(InputMetadataProvider.class);
mockTemplateExpansion(owner, ImmutableList.of(firstProducer, secondProducer));
// Switch to the fine locks with an unrelated rewound action first: the coarse lock would
// exclude the consumer regardless of which keys guard the trees.
rewind(newAction());
try (SilentCloseable secondPreparation =
synchronizer.enterActionPreparation(secondProducer, /* wasRewound= */ true)) {
// The consumer of the first tree is admitted while the producer of the second tree holds its
// write lock.
var consumerExecution =
new TestThread(
() -> {
try (SilentCloseable execution =
synchronizer.enterActionExecution(
consumer, /* wasRewound= */ false, metadataProvider)) {}
});
consumerExecution.start();
consumerExecution.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
// The producer of the first tree still waits for the consumer.
var firstPreparation = new TestThread(() -> rewind(firstProducer));
try (SilentCloseable execution =
synchronizer.enterActionExecution(consumer, /* wasRewound= */ false, metadataProvider)) {
firstPreparation.start();
waitUntilBlocked(firstPreparation);
}
firstPreparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
}
}
/**
* Regression test for a lock cycle between the rewound expanded action P of an action template, a
* rewound consumer C of the tree artifact P populates, and an action D expanded from a downstream
* template that consumes both P's file and C's output:
*
* <ol>
* <li>D holds the read lock of P and waits for the read lock of C, which C holds for writing.
* <li>P waits for the write lock of its own key, which D holds for reading.
* <li>C, which depends on P but not on D, requests the read lock of P.
* </ol>
*
* <p>C must be admitted even though P is waiting for the same lock as a writer. Any lock that
* queues readers behind waiting writers would make C wait for P and close the cycle. {@link
* java.util.concurrent.locks.ReentrantReadWriteLock} does so in both fairness modes and {@link
* java.util.concurrent.locks.StampedLock} does so in {@code readLock}, but not in {@code
* readLockInterruptibly}, which only waits for a writer that holds the lock.
*/
@Test
public void expandedActionRewound_treeConsumerNotQueuedBehindWaitingProducer() throws Exception {
FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
ArtifactRoot root = ArtifactRoot.asDerivedRoot(fs.getPath("/exec"), RootType.OUTPUT, "out");
var owner = ActionsTestUtil.NULL_ARTIFACT_OWNER;
// The upstream template (action 0 of the owner) declares a tree artifact that its single
// expanded action P populates with a file.
SpecialArtifact upstreamTree =
newTreeArtifact(root, "upstream", ActionLookupData.create(owner, 0));
ActionTemplateExpansionKey upstreamExpansion = ActionTemplateExpansionValue.key(owner, 0);
TreeFileArtifact upstreamFile =
TreeFileArtifact.createTemplateExpansionOutput(upstreamTree, "file", upstreamExpansion);
upstreamFile.setGeneratingActionKey(ActionLookupData.create(upstreamExpansion, 0));
Action upstreamAction = newAction(ImmutableList.of(upstreamFile), ImmutableList.of());
// The regular action C (action 1 of the owner) consumes the whole upstream tree artifact.
DerivedArtifact treeConsumerOutput =
(DerivedArtifact) ActionsTestUtil.createArtifact(root, "tree_consumer.out");
treeConsumerOutput.setGeneratingActionKey(ActionLookupData.create(owner, 1));
Action treeConsumer =
newAction(ImmutableList.of(treeConsumerOutput), ImmutableList.of(upstreamTree));
// The downstream template (action 2 of the owner) is expanded over the upstream tree artifact.
// Its expanded action D consumes the file generated by P as well as the output of C and
// acquires their read locks in this order.
SpecialArtifact downstreamTree =
newTreeArtifact(root, "downstream", ActionLookupData.create(owner, 2));
ActionTemplateExpansionKey downstreamExpansion = ActionTemplateExpansionValue.key(owner, 2);
TreeFileArtifact downstreamFile =
TreeFileArtifact.createTemplateExpansionOutput(downstreamTree, "file", downstreamExpansion);
downstreamFile.setGeneratingActionKey(ActionLookupData.create(downstreamExpansion, 0));
Action downstreamAction =
newAction(
ImmutableList.of(downstreamFile), ImmutableList.of(upstreamFile, treeConsumerOutput));
InputMetadataProvider metadataProvider = mock(InputMetadataProvider.class);
mockTemplateExpansion(owner, ImmutableList.of(upstreamAction));
// C is rewound and prepares for its re-execution, which makes it hold the write lock guarding
// its output until the end of its execution.
SilentCloseable treeConsumerPreparation =
synchronizer.enterActionPreparation(treeConsumer, /* wasRewound= */ true);
// D enters execution, acquires the read lock guarding the upstream file and then blocks on the
// read lock guarding the output of C.
var downstreamExecution =
new TestThread(
() -> {
try (SilentCloseable execution =
synchronizer.enterActionExecution(
downstreamAction, /* wasRewound= */ false, metadataProvider)) {}
});
downstreamExecution.start();
waitUntilBlocked(downstreamExecution);
// P is rewound and prepares for its re-execution, which blocks on the read lock held by D.
var upstreamPreparation = new TestThread(() -> rewind(upstreamAction));
upstreamPreparation.start();
waitUntilBlocked(upstreamPreparation);
// C enters execution, which requires the read lock guarding the upstream tree artifact. It must
// not wait for P, which waits for D, which waits for C.
var treeConsumerExecution =
new TestThread(
() -> {
try (treeConsumerPreparation;
SilentCloseable execution =
synchronizer.enterActionExecution(
treeConsumer, /* wasRewound= */ true, metadataProvider)) {}
});
treeConsumerExecution.start();
treeConsumerExecution.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
downstreamExecution.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
upstreamPreparation.joinAndAssertState(DEADLOCK_TIMEOUT_MILLIS);
}
/**
* Makes the given owner's only action an {@link ActionTemplate} whose expansion consists of the
* given actions.
*/
private void mockTemplateExpansion(ActionLookupKey owner, ImmutableList<Action> expandedActions)
throws InterruptedException {
ActionLookupValue ownerValue = mock(ActionLookupValue.class);
when(ownerValue.getActions()).thenReturn(ImmutableList.of(mock(ActionTemplate.class)));
when(graph.getValue(owner)).thenReturn(ownerValue);
when(graph.getValue(ActionTemplateExpansionValue.key(owner, 0)))
.thenReturn(new ActionTemplateExpansionValue(ImmutableList.copyOf(expandedActions)));
}
@SuppressWarnings("ThreadPriorityCheck")
private static void waitUntilBlocked(Thread thread) {
Thread.State state;
while ((state = thread.getState()) != Thread.State.WAITING) {
assertThat(state).isNotEqualTo(Thread.State.TERMINATED);
Thread.yield();
}
}
private void rewind(Action action) throws Exception {
try (SilentCloseable ignored = synchronizer.enterActionPreparation(action, true)) {
// Cancellation happens while entering preparation.
}
}
private static Action newAction() {
FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
ArtifactRoot outputRoot =
ArtifactRoot.asDerivedRoot(fs.getPath("/exec"), RootType.OUTPUT, "out");
DerivedArtifact output = (DerivedArtifact) ActionsTestUtil.createArtifact(outputRoot, "output");
output.setGeneratingActionKey(ActionsTestUtil.NULL_ACTION_LOOKUP_DATA);
return newAction(ImmutableList.of(output), ImmutableList.of());
}
private static Action newAction(
ImmutableList<? extends Artifact> outputs, ImmutableList<? extends Artifact> inputs) {
Action action = mock(Action.class);
when(action.getPrimaryOutput()).thenReturn(outputs.get(0));
when(action.getOutputs()).thenReturn(ImmutableList.copyOf(outputs));
when(action.getInputs()).thenReturn(NestedSetBuilder.wrap(Order.STABLE_ORDER, inputs));
return action;
}
private static SpecialArtifact newTreeArtifact(
ArtifactRoot root, String name, ActionLookupData generatingActionKey) {
SpecialArtifact tree =
SpecialArtifact.create(
root,
root.getExecPath().getRelative(name),
generatingActionKey.getActionLookupKey(),
SpecialArtifactType.TREE);
tree.setGeneratingActionKey(generatingActionKey);
return tree;
}
private static final class EqualCancellable
implements RemoteRewoundActionSynchronizer.Cancellable {
private final AtomicInteger cancellations = new AtomicInteger();
@Override
public void requestCancellation() {
cancellations.incrementAndGet();
}
@Override
public void awaitCompletion() {}
@Override
public boolean equals(Object obj) {
return obj instanceof EqualCancellable;
}
@Override
public int hashCode() {
return 1;
}
}
}