remote: make the dynamic spawn scheduler work. Fixes #8646

This change fixes the correctness issue of dynamic spawn scheduler when being used with remote execution. See #8646 for more details.

There's a performance issue remaining: #8647

Closes #8648.

PiperOrigin-RevId: 253998300
diff --git a/src/main/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCache.java b/src/main/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCache.java
index 1e9b650..78c7e3d 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCache.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCache.java
@@ -46,6 +46,7 @@
 import com.google.devtools.build.lib.actions.UserExecException;
 import com.google.devtools.build.lib.actions.cache.MetadataInjector;
 import com.google.devtools.build.lib.concurrent.ThreadSafety;
+import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
 import com.google.devtools.build.lib.profiler.Profiler;
 import com.google.devtools.build.lib.profiler.SilentCloseable;
 import com.google.devtools.build.lib.remote.AbstractRemoteActionCache.ActionResultMetadata.DirectoryMetadata;
@@ -70,6 +71,7 @@
 import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
@@ -83,6 +85,12 @@
 @ThreadSafety.ThreadSafe
 public abstract class AbstractRemoteActionCache implements AutoCloseable {
 
+  /** See {@link SpawnExecutionContext#lockOutputFiles()}. */
+  @FunctionalInterface
+  interface OutputFilesLocker {
+    void lock() throws InterruptedException;
+  }
+
   private static final ListenableFuture<Void> COMPLETED_SUCCESS = SettableFuture.create();
   private static final ListenableFuture<byte[]> EMPTY_BYTES = SettableFuture.create();
 
@@ -161,16 +169,26 @@
     return outerF;
   }
 
+  private static Path toTmpDownloadPath(Path actualPath) {
+    return actualPath.getParentDirectory().getRelative(actualPath.getBaseName() + ".tmp");
+  }
+
   /**
    * Download the output files and directory trees of a remotely executed action to the local
    * machine, as well stdin / stdout to the given files.
    *
    * <p>In case of failure, this method deletes any output files it might have already created.
    *
+   * @param outputFilesLocker ensures that we are the only ones writing to the output files when
+   *     using the dynamic spawn strategy.
    * @throws IOException in case of a cache miss or if the remote cache is unavailable.
    * @throws ExecException in case clean up after a failed download failed.
    */
-  public void download(ActionResult result, Path execRoot, FileOutErr origOutErr)
+  public void download(
+      ActionResult result,
+      Path execRoot,
+      FileOutErr origOutErr,
+      OutputFilesLocker outputFilesLocker)
       throws ExecException, IOException, InterruptedException {
     ActionResultMetadata metadata = parseActionResultMetadata(result, execRoot);
 
@@ -182,7 +200,8 @@
             .map(
                 (file) -> {
                   try {
-                    ListenableFuture<Void> download = downloadFile(file.path(), file.digest());
+                    ListenableFuture<Void> download =
+                        downloadFile(toTmpDownloadPath(file.path()), file.digest());
                     return Futures.transform(download, (d) -> file, directExecutor());
                   } catch (IOException e) {
                     return Futures.<FileMetadata>immediateFailedFuture(e);
@@ -209,10 +228,8 @@
 
     for (ListenableFuture<FileMetadata> download : downloads) {
       try {
-        FileMetadata outputFile = getFromFuture(download);
-        if (outputFile != null) {
-          outputFile.path().setExecutable(outputFile.isExecutable());
-        }
+        // Wait for all downloads to finish.
+        getFromFuture(download);
       } catch (IOException e) {
         downloadException = downloadException == null ? e : downloadException;
       } catch (InterruptedException e) {
@@ -222,10 +239,9 @@
 
     if (downloadException != null || interruptedException != null) {
       try {
-        // Delete any (partially) downloaded output files, since any subsequent local execution
-        // of this action may expect none of the output files to exist.
+        // Delete any (partially) downloaded output files.
         for (OutputFile file : result.getOutputFilesList()) {
-          execRoot.getRelative(file.getPath()).delete();
+          toTmpDownloadPath(execRoot.getRelative(file.getPath())).delete();
         }
         for (OutputDirectory directory : result.getOutputDirectoriesList()) {
           // Only delete the directories below the output directories because the output
@@ -261,6 +277,12 @@
       tmpOutErr.clearErr();
     }
 
+    // Ensure that we are the only ones writing to the output files when using the dynamic spawn
+    // strategy.
+    outputFilesLocker.lock();
+
+    moveOutputsToFinalLocation(downloads);
+
     List<SymlinkMetadata> symlinksInDirectories = new ArrayList<>();
     for (Entry<Path, DirectoryMetadata> entry : metadata.directories()) {
       entry.getKey().createDirectoryAndParents();
@@ -275,6 +297,36 @@
     createSymlinks(symlinks);
   }
 
+  /**
+   * Copies moves the downloaded outputs from their download location to their declared location.
+   */
+  private void moveOutputsToFinalLocation(List<ListenableFuture<FileMetadata>> downloads)
+      throws IOException, InterruptedException {
+    List<FileMetadata> finishedDownloads = new ArrayList<>(downloads.size());
+    for (ListenableFuture<FileMetadata> finishedDownload : downloads) {
+      FileMetadata outputFile = getFromFuture(finishedDownload);
+      if (outputFile != null) {
+        finishedDownloads.add(outputFile);
+      }
+    }
+    /*
+     * Sort the list lexicographically based on its temporary download path in order to avoid
+     * filename clashes when moving the files:
+     *
+     * Consider an action that produces two outputs foo and foo.tmp. These outputs would initially
+     * be downloaded to foo.tmp and foo.tmp.tmp. When renaming them to foo and foo.tmp we need to
+     * ensure that rename(foo.tmp, foo) happens before rename(foo.tmp.tmp, foo.tmp). We ensure this
+     * by doing the renames in lexicographical order of the download names.
+     */
+    Collections.sort(finishedDownloads, Comparator.comparing(f -> toTmpDownloadPath(f.path())));
+
+    // Move the output files from their temporary name to the actual output file name.
+    for (FileMetadata outputFile : finishedDownloads) {
+      FileSystemUtils.moveFile(toTmpDownloadPath(outputFile.path()), outputFile.path());
+      outputFile.path().setExecutable(outputFile.isExecutable());
+    }
+  }
+
   private void createSymlinks(Iterable<SymlinkMetadata> symlinks) throws IOException {
     for (SymlinkMetadata symlink : symlinks) {
       if (symlink.target().isAbsolute()) {
@@ -376,6 +428,8 @@
    * @param execRoot the execution root
    * @param metadataInjector the action's metadata injector that allows this method to inject
    *     metadata about an action output instead of downloading the output
+   * @param outputFilesLocker ensures that we are the only ones writing to the output files when
+   *     using the dynamic spawn strategy.
    * @throws IOException in case of failure
    * @throws InterruptedException in case of receiving an interrupt
    */
@@ -386,7 +440,8 @@
       @Nullable PathFragment inMemoryOutputPath,
       OutErr outErr,
       Path execRoot,
-      MetadataInjector metadataInjector)
+      MetadataInjector metadataInjector,
+      OutputFilesLocker outputFilesLocker)
       throws IOException, InterruptedException {
     Preconditions.checkState(
         result.getExitCode() == 0,
@@ -403,6 +458,10 @@
               + "--experimental_remote_download_outputs=minimal");
     }
 
+    // Ensure that when using dynamic spawn strategy that we are the only ones writing to the
+    // output files.
+    outputFilesLocker.lock();
+
     ActionInput inMemoryOutput = null;
     Digest inMemoryOutputDigest = null;
     for (ActionInput output : outputs) {
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnCache.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnCache.java
index 4bd91ae..72ba687 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnCache.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnCache.java
@@ -167,7 +167,8 @@
           if (downloadOutputs) {
             try (SilentCloseable c =
                 prof.profile(ProfilerTask.REMOTE_DOWNLOAD, "download outputs")) {
-              remoteCache.download(result, execRoot, context.getFileOutErr());
+              remoteCache.download(
+                  result, execRoot, context.getFileOutErr(), context::lockOutputFiles);
             }
           } else {
             PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
@@ -181,7 +182,8 @@
                       inMemoryOutputPath,
                       context.getFileOutErr(),
                       execRoot,
-                      context.getMetadataInjector());
+                      context.getMetadataInjector(),
+                      context::lockOutputFiles);
             }
           }
           SpawnResult spawnResult =
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnRunner.java
index 3e57b5c..cb6463a 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteSpawnRunner.java
@@ -312,7 +312,8 @@
     InMemoryOutput inMemoryOutput = null;
     if (downloadOutputs) {
       try (SilentCloseable c = Profiler.instance().profile(REMOTE_DOWNLOAD, "download outputs")) {
-        remoteCache.download(actionResult, execRoot, context.getFileOutErr());
+        remoteCache.download(
+            actionResult, execRoot, context.getFileOutErr(), context::lockOutputFiles);
       }
     } else {
       PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
@@ -325,7 +326,8 @@
                 inMemoryOutputPath,
                 context.getFileOutErr(),
                 execRoot,
-                context.getMetadataInjector());
+                context.getMetadataInjector(),
+                context::lockOutputFiles);
       }
     }
     return createSpawnResult(actionResult.getExitCode(), cacheHit, getName(), inMemoryOutput);
@@ -405,10 +407,11 @@
       return execLocallyAndUpload(
           spawn, context, inputMap, remoteCache, actionKey, action, command, uploadLocalResults);
     }
-    return handleError(cause, context.getFileOutErr(), actionKey);
+    return handleError(cause, context.getFileOutErr(), actionKey, context);
   }
 
-  private SpawnResult handleError(IOException exception, FileOutErr outErr, ActionKey actionKey)
+  private SpawnResult handleError(
+      IOException exception, FileOutErr outErr, ActionKey actionKey, SpawnExecutionContext context)
       throws ExecException, InterruptedException, IOException {
     if (exception.getCause() instanceof ExecutionStatusException) {
       ExecutionStatusException e = (ExecutionStatusException) exception.getCause();
@@ -417,7 +420,7 @@
         maybeDownloadServerLogs(resp, actionKey);
         if (resp.hasResult()) {
           // We try to download all (partial) results even on server error, for debuggability.
-          remoteCache.download(resp.getResult(), execRoot, outErr);
+          remoteCache.download(resp.getResult(), execRoot, outErr, context::lockOutputFiles);
         }
       }
       if (e.isExecutionTimeout()) {
diff --git a/src/test/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCacheTests.java b/src/test/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCacheTests.java
index 2c4b95b..b46a86e 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCacheTests.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/AbstractRemoteActionCacheTests.java
@@ -20,6 +20,7 @@
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -52,6 +53,7 @@
 import com.google.devtools.build.lib.actions.cache.MetadataInjector;
 import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
 import com.google.devtools.build.lib.clock.JavaClock;
+import com.google.devtools.build.lib.remote.AbstractRemoteActionCache.OutputFilesLocker;
 import com.google.devtools.build.lib.remote.AbstractRemoteActionCache.UploadManifest;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
@@ -72,6 +74,7 @@
 import com.google.protobuf.Message;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -86,12 +89,16 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.Mock;
 import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
 
 /** Tests for {@link AbstractRemoteActionCache}. */
 @RunWith(JUnit4.class)
 public class AbstractRemoteActionCacheTests {
 
+  @Mock private OutputFilesLocker outputFilesLocker;
+
   private FileSystem fs;
   private Path execRoot;
   ArtifactRoot artifactRoot;
@@ -106,6 +113,7 @@
 
   @Before
   public void setUp() throws Exception {
+    MockitoAnnotations.initMocks(this);
     fs = new InMemoryFileSystem(new JavaClock(), DigestHashFunction.SHA256);
     execRoot = fs.getPath("/execroot");
     execRoot.createDirectoryAndParents();
@@ -270,7 +278,7 @@
             digestUtil, result, execRoot, /*uploadSymlinks=*/ true, /*allowSymlinks=*/ true);
     IOException e = assertThrows(IOException.class, () -> um.addFiles(ImmutableList.of(link)));
     assertThat(e).hasMessageThat().contains("dangling");
-      assertThat(e).hasMessageThat().contains("/execroot/link");
+    assertThat(e).hasMessageThat().contains("/execroot/link");
     assertThat(e).hasMessageThat().contains("target");
   }
 
@@ -502,7 +510,7 @@
             digestUtil, result, execRoot, /*uploadSymlinks=*/ true, /*allowSymlinks=*/ true);
     IOException e = assertThrows(IOException.class, () -> um.addFiles(ImmutableList.of(dir)));
     assertThat(e).hasMessageThat().contains("dangling");
-      assertThat(e).hasMessageThat().contains("/execroot/dir/link");
+    assertThat(e).hasMessageThat().contains("/execroot/dir/link");
     assertThat(e).hasMessageThat().contains("/execroot/target");
   }
 
@@ -521,7 +529,7 @@
             digestUtil, result, execRoot, /*uploadSymlinks=*/ true, /*allowSymlinks=*/ false);
     ExecException e = assertThrows(ExecException.class, () -> um.addFiles(ImmutableList.of(dir)));
     assertThat(e).hasMessageThat().contains("symbolic link");
-      assertThat(e).hasMessageThat().contains("dir/link");
+    assertThat(e).hasMessageThat().contains("dir/link");
     assertThat(e).hasMessageThat().contains("--remote_allow_symlink_upload");
   }
 
@@ -531,10 +539,11 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFileSymlinksBuilder().setPath("a/b/link").setTarget("../../foo");
     // Doesn't check for dangling links, hence download succeeds.
-    cache.download(result.build(), execRoot, null);
+    cache.download(result.build(), execRoot, null, outputFilesLocker);
     Path path = execRoot.getRelative("a/b/link");
     assertThat(path.isSymbolicLink()).isTrue();
     assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("../../foo"));
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -543,10 +552,11 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectorySymlinksBuilder().setPath("a/b/link").setTarget("foo");
     // Doesn't check for dangling links, hence download succeeds.
-    cache.download(result.build(), execRoot, null);
+    cache.download(result.build(), execRoot, null, outputFilesLocker);
     Path path = execRoot.getRelative("a/b/link");
     assertThat(path.isSymbolicLink()).isTrue();
     assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("foo"));
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -562,10 +572,11 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectoriesBuilder().setPath("dir").setTreeDigest(treeDigest);
     // Doesn't check for dangling links, hence download succeeds.
-    cache.download(result.build(), execRoot, null);
+    cache.download(result.build(), execRoot, null, outputFilesLocker);
     Path path = execRoot.getRelative("dir/link");
     assertThat(path.isSymbolicLink()).isTrue();
     assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("../foo"));
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -574,9 +585,12 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectorySymlinksBuilder().setPath("foo").setTarget("/abs/link");
     IOException expected =
-        assertThrows(IOException.class, () -> cache.download(result.build(), execRoot, null));
+        assertThrows(
+            IOException.class,
+            () -> cache.download(result.build(), execRoot, null, outputFilesLocker));
     assertThat(expected).hasMessageThat().contains("/abs/link");
     assertThat(expected).hasMessageThat().contains("absolute path");
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -585,9 +599,12 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFileSymlinksBuilder().setPath("foo").setTarget("/abs/link");
     IOException expected =
-        assertThrows(IOException.class, () -> cache.download(result.build(), execRoot, null));
+        assertThrows(
+            IOException.class,
+            () -> cache.download(result.build(), execRoot, null, outputFilesLocker));
     assertThat(expected).hasMessageThat().contains("/abs/link");
     assertThat(expected).hasMessageThat().contains("absolute path");
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -603,10 +620,13 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectoriesBuilder().setPath("dir").setTreeDigest(treeDigest);
     IOException expected =
-        assertThrows(IOException.class, () -> cache.download(result.build(), execRoot, null));
+        assertThrows(
+            IOException.class,
+            () -> cache.download(result.build(), execRoot, null, outputFilesLocker));
     assertThat(expected).hasMessageThat().contains("dir/link");
-      assertThat(expected).hasMessageThat().contains("/foo");
+    assertThat(expected).hasMessageThat().contains("/foo");
     assertThat(expected).hasMessageThat().contains("absolute path");
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -623,11 +643,13 @@
     result.addOutputFiles(
         OutputFile.newBuilder().setPath("outputdir/outputfile").setDigest(outputFileDigest));
     result.addOutputFiles(OutputFile.newBuilder().setPath("otherfile").setDigest(otherFileDigest));
-    assertThrows(IOException.class, () -> cache.download(result.build(), execRoot, null));
+    assertThrows(
+        IOException.class, () -> cache.download(result.build(), execRoot, null, outputFilesLocker));
     assertThat(cache.getNumFailedDownloads()).isEqualTo(1);
-      assertThat(execRoot.getRelative("outputdir").exists()).isTrue();
-      assertThat(execRoot.getRelative("outputdir/outputfile").exists()).isFalse();
+    assertThat(execRoot.getRelative("outputdir").exists()).isTrue();
+    assertThat(execRoot.getRelative("outputdir/outputfile").exists()).isFalse();
     assertThat(execRoot.getRelative("otherfile").exists()).isFalse();
+    verify(outputFilesLocker, never()).lock();
   }
 
   @Test
@@ -654,11 +676,14 @@
     IOException e =
         assertThrows(
             IOException.class,
-            () -> cache.download(result, execRoot, new FileOutErr(stdout, stderr)));
+            () ->
+                cache.download(
+                    result, execRoot, new FileOutErr(stdout, stderr), outputFilesLocker));
     assertThat(cache.getNumSuccessfulDownloads()).isEqualTo(2);
-      assertThat(cache.getNumFailedDownloads()).isEqualTo(1);
-      assertThat(cache.getDownloadQueueSize()).isEqualTo(3);
+    assertThat(cache.getNumFailedDownloads()).isEqualTo(1);
+    assertThat(cache.getDownloadQueueSize()).isEqualTo(3);
     assertThat(Throwables.getRootCause(e)).hasMessageThat().isEqualTo("download failed");
+    verify(outputFilesLocker, never()).lock();
   }
 
   @Test
@@ -684,7 +709,7 @@
             .setStderrDigest(digestStderr)
             .build();
 
-    cache.download(result, execRoot, spyOutErr);
+    cache.download(result, execRoot, spyOutErr, outputFilesLocker);
 
     verify(spyOutErr, Mockito.times(2)).childOutErr();
     verify(spyChildOutErr).clearOut();
@@ -698,6 +723,8 @@
     } catch (IOException err) {
       throw new AssertionError("outErr should still be writable after download finished.", err);
     }
+
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -723,8 +750,8 @@
             .setStdoutDigest(digestStdout)
             .setStderrDigest(digestStderr)
             .build();
-    IOException e =
-        assertThrows(IOException.class, () -> cache.download(result, execRoot, spyOutErr));
+    assertThrows(
+        IOException.class, () -> cache.download(result, execRoot, spyOutErr, outputFilesLocker));
     verify(spyOutErr, Mockito.times(2)).childOutErr();
     verify(spyChildOutErr).clearOut();
     verify(spyChildOutErr).clearErr();
@@ -737,6 +764,39 @@
     } catch (IOException err) {
       throw new AssertionError("outErr should still be writable after download failed.", err);
     }
+
+    verify(outputFilesLocker, never()).lock();
+  }
+
+  @Test
+  public void testDownloadClashes() throws Exception {
+    // Test that injecting the metadata for a remote output file works
+
+    // arrange
+    DefaultRemoteActionCache remoteCache = newTestCache();
+    Digest d1 = remoteCache.addContents("content1");
+    Digest d2 = remoteCache.addContents("content2");
+    ActionResult r =
+        ActionResult.newBuilder()
+            .setExitCode(0)
+            .addOutputFiles(OutputFile.newBuilder().setPath("outputs/foo.tmp").setDigest(d1))
+            .addOutputFiles(OutputFile.newBuilder().setPath("outputs/foo").setDigest(d2))
+            .build();
+
+    Artifact a1 = ActionsTestUtil.createArtifact(artifactRoot, "foo.tmp");
+    Artifact a2 = ActionsTestUtil.createArtifact(artifactRoot, "foo");
+
+    // act
+
+    remoteCache.download(r, execRoot, new FileOutErr(), outputFilesLocker);
+
+    // assert
+
+    assertThat(FileSystemUtils.readContent(a1.getPath(), StandardCharsets.UTF_8))
+        .isEqualTo("content1");
+    assertThat(FileSystemUtils.readContent(a2.getPath(), StandardCharsets.UTF_8))
+        .isEqualTo("content2");
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -767,7 +827,8 @@
             /* inMemoryOutputPath= */ null,
             new FileOutErr(),
             execRoot,
-            injector);
+            injector,
+            outputFilesLocker);
 
     // assert
     assertThat(inMemoryOutput).isNull();
@@ -778,6 +839,8 @@
 
     Path outputBase = artifactRoot.getRoot().asPath();
     assertThat(outputBase.readdir(Symlinks.NOFOLLOW)).isEmpty();
+
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -826,7 +889,8 @@
             /* inMemoryOutputPath= */ null,
             new FileOutErr(),
             execRoot,
-            injector);
+            injector,
+            outputFilesLocker);
 
     // assert
     assertThat(inMemoryOutput).isNull();
@@ -844,6 +908,8 @@
 
     Path outputBase = artifactRoot.getRoot().asPath();
     assertThat(outputBase.readdir(Symlinks.NOFOLLOW)).isEmpty();
+
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -896,8 +962,11 @@
                     /* inMemoryOutputPath= */ null,
                     new FileOutErr(),
                     execRoot,
-                    injector));
+                    injector,
+                    outputFilesLocker));
     assertThat(e).isEqualTo(downloadTreeException);
+
+    verify(outputFilesLocker, never()).lock();
   }
 
   @Test
@@ -920,7 +989,13 @@
     // act
     InMemoryOutput inMemoryOutput =
         remoteCache.downloadMinimal(
-            r, ImmutableList.of(), /* inMemoryOutputPath= */ null, outErr, execRoot, injector);
+            r,
+            ImmutableList.of(),
+            /* inMemoryOutputPath= */ null,
+            outErr,
+            execRoot,
+            injector,
+            outputFilesLocker);
 
     // assert
     assertThat(inMemoryOutput).isNull();
@@ -929,6 +1004,8 @@
 
     Path outputBase = artifactRoot.getRoot().asPath();
     assertThat(outputBase.readdir(Symlinks.NOFOLLOW)).isEmpty();
+
+    verify(outputFilesLocker).lock();
   }
 
   @Test
@@ -959,7 +1036,8 @@
             inMemoryOutputPathFragment,
             new FileOutErr(),
             execRoot,
-            injector);
+            injector,
+            outputFilesLocker);
 
     // assert
     assertThat(inMemoryOutput).isNotNull();
@@ -974,6 +1052,8 @@
 
     Path outputBase = artifactRoot.getRoot().asPath();
     assertThat(outputBase.readdir(Symlinks.NOFOLLOW)).isEmpty();
+
+    verify(outputFilesLocker).lock();
   }
 
   private DefaultRemoteActionCache newTestCache() {
diff --git a/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteCacheTest.java b/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteCacheTest.java
index 68a2d45..014145a 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteCacheTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteCacheTest.java
@@ -346,7 +346,7 @@
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputFilesBuilder().setPath("b/empty").setDigest(emptyDigest);
     result.addOutputFilesBuilder().setPath("a/bar").setDigest(barDigest).setIsExecutable(true);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("b/empty"))).isEqualTo(emptyDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar"))).isEqualTo(barDigest);
@@ -379,7 +379,7 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/qux"))).isEqualTo(quxDigest);
@@ -397,7 +397,7 @@
 
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(execRoot.getRelative("a/bar").isDirectory()).isTrue();
   }
@@ -436,7 +436,7 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/wobble/qux"))).isEqualTo(quxDigest);
diff --git a/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteExecutionClientTest.java b/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteExecutionClientTest.java
index b89d6f0..40e7074 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteExecutionClientTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/GrpcRemoteExecutionClientTest.java
@@ -50,28 +50,21 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
-import com.google.devtools.build.lib.actions.Artifact;
-import com.google.devtools.build.lib.actions.Artifact.ArtifactExpander;
-import com.google.devtools.build.lib.actions.ArtifactPathResolver;
 import com.google.devtools.build.lib.actions.ExecException;
-import com.google.devtools.build.lib.actions.MetadataProvider;
 import com.google.devtools.build.lib.actions.ResourceSet;
 import com.google.devtools.build.lib.actions.SimpleSpawn;
 import com.google.devtools.build.lib.actions.SpawnResult;
-import com.google.devtools.build.lib.actions.cache.MetadataInjector;
 import com.google.devtools.build.lib.analysis.BlazeVersionInfo;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.clock.JavaClock;
 import com.google.devtools.build.lib.exec.ExecutionOptions;
 import com.google.devtools.build.lib.exec.SpawnExecException;
-import com.google.devtools.build.lib.exec.SpawnInputExpander;
-import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus;
-import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
 import com.google.devtools.build.lib.exec.util.FakeOwner;
 import com.google.devtools.build.lib.remote.RemoteRetrier.ExponentialBackoff;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
+import com.google.devtools.build.lib.remote.util.FakeSpawnExecutionContext;
 import com.google.devtools.build.lib.remote.util.TestUtils;
 import com.google.devtools.build.lib.remote.util.TracingMetadataUtils;
 import com.google.devtools.build.lib.util.io.FileOutErr;
@@ -101,11 +94,7 @@
 import io.grpc.inprocess.InProcessServerBuilder;
 import io.grpc.stub.StreamObserver;
 import io.grpc.util.MutableHandlerRegistry;
-import java.io.IOException;
-import java.time.Duration;
-import java.util.Collection;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicInteger;
 import javax.annotation.Nullable;
@@ -127,14 +116,6 @@
 
   private static final DigestUtil DIGEST_UTIL = new DigestUtil(DigestHashFunction.SHA256);
 
-  private static final ArtifactExpander SIMPLE_ARTIFACT_EXPANDER =
-      new ArtifactExpander() {
-        @Override
-        public void expand(Artifact artifact, Collection<? super Artifact> output) {
-          output.add(artifact);
-        }
-      };
-
   private final MutableHandlerRegistry serviceRegistry = new MutableHandlerRegistry();
   private FileSystem fs;
   private Path execRoot;
@@ -159,70 +140,6 @@
                   .build())
           .build();
 
-  private final SpawnExecutionContext simplePolicy =
-      new SpawnExecutionContext() {
-        @Override
-        public int getId() {
-          return 0;
-        }
-
-        @Override
-        public void prefetchInputs() {
-        }
-
-        @Override
-        public void lockOutputFiles() throws InterruptedException {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean speculating() {
-          return false;
-        }
-
-        @Override
-        public MetadataProvider getMetadataProvider() {
-          return fakeFileCache;
-        }
-
-        @Override
-        public ArtifactExpander getArtifactExpander() {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public Duration getTimeout() {
-          return Duration.ZERO;
-        }
-
-        @Override
-        public FileOutErr getFileOutErr() {
-          return outErr;
-        }
-
-        @Override
-        public SortedMap<PathFragment, ActionInput> getInputMapping(
-            boolean expandTreeArtifactsInRunfiles) throws IOException {
-          return new SpawnInputExpander(execRoot, /*strict*/ false)
-              .getInputMapping(
-                  simpleSpawn,
-                  SIMPLE_ARTIFACT_EXPANDER,
-                  ArtifactPathResolver.IDENTITY,
-                  fakeFileCache,
-                  true);
-        }
-
-        @Override
-        public void report(ProgressStatus state, String name) {
-          // TODO(ulfjack): Test that the right calls are made.
-        }
-
-        @Override
-        public MetadataInjector getMetadataInjector() {
-          throw new UnsupportedOperationException();
-        }
-      };
-
   @BeforeClass
   public static void beforeEverything() {
     retryService = MoreExecutors.listeningDecorator(Executors.newScheduledThreadPool(1));
@@ -354,7 +271,10 @@
           }
         });
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.isCacheHit()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
@@ -397,7 +317,9 @@
           }
         });
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.exitCode()).isEqualTo(1);
   }
 
@@ -436,7 +358,10 @@
           }
         });
 
-    client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
+    client.exec(simpleSpawn, policy);
   }
 
   @Test
@@ -460,12 +385,15 @@
     serviceRegistry.addService(
         new FakeImmutableCacheByteStreamImpl(stdOutDigest, "stdout", stdErrDigest, "stderr"));
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isTrue();
     assertThat(outErr.outAsLatin1()).isEqualTo("stdout");
     assertThat(outErr.errAsLatin1()).isEqualTo("stderr");
+
   }
 
   @Test
@@ -485,7 +413,9 @@
           }
         });
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isTrue();
@@ -622,7 +552,9 @@
     serviceRegistry.addService(
         ServerInterceptors.intercept(mockByteStreamImpl, new RequestHeadersValidator()));
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isFalse();
@@ -777,7 +709,9 @@
             ArgumentMatchers.<StreamObserver<ReadResponse>>any());
     serviceRegistry.addService(mockByteStreamImpl);
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isFalse();
@@ -885,7 +819,9 @@
             ArgumentMatchers.<StreamObserver<ReadResponse>>any());
     serviceRegistry.addService(mockByteStreamImpl);
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isFalse();
@@ -918,8 +854,11 @@
           }
         });
 
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
     SpawnExecException expected =
-        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, simplePolicy));
+        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, policy));
     assertThat(expected.getSpawnResult().status())
         .isEqualTo(SpawnResult.Status.EXECUTION_FAILED_CATASTROPHICALLY);
     // Ensure we also got back the stack trace.
@@ -939,8 +878,10 @@
           }
         });
 
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
     ExecException expected =
-        assertThrows(ExecException.class, () -> client.exec(simpleSpawn, simplePolicy));
+        assertThrows(ExecException.class, () -> client.exec(simpleSpawn, policy));
     assertThat(expected).hasMessageThat().contains("whoa"); // Error details.
     // Ensure we also got back the stack trace.
     assertThat(expected)
@@ -997,8 +938,11 @@
           }
         });
 
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
     SpawnExecException expected =
-        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, simplePolicy));
+        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, policy));
     assertThat(expected.getSpawnResult().status())
         .isEqualTo(SpawnResult.Status.REMOTE_CACHE_FAILED);
       assertThat(expected).hasMessageThat().contains(DIGEST_UTIL.toString(stdOutDigest));
@@ -1058,8 +1002,10 @@
           }
         });
 
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
     SpawnExecException expected =
-        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, simplePolicy));
+        assertThrows(SpawnExecException.class, () -> client.exec(simpleSpawn, policy));
     assertThat(expected.getSpawnResult().status())
         .isEqualTo(SpawnResult.Status.REMOTE_CACHE_FAILED);
       assertThat(expected).hasMessageThat().contains(DIGEST_UTIL.toString(stdOutDigest));
@@ -1150,7 +1096,10 @@
           }
         });
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isFalse();
@@ -1246,7 +1195,10 @@
           }
         });
 
-    SpawnResult result = client.exec(simpleSpawn, simplePolicy);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(simpleSpawn, fakeFileCache, execRoot, outErr);
+
+    SpawnResult result = client.exec(simpleSpawn, policy);
     assertThat(result.setupSuccess()).isTrue();
     assertThat(result.exitCode()).isEqualTo(0);
     assertThat(result.isCacheHit()).isFalse();
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java
index c1e712a..efdf4eb 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java
@@ -272,13 +272,13 @@
               }
             })
         .when(remoteCache)
-        .download(actionResult, execRoot, outErr);
+        .download(eq(actionResult), eq(execRoot), eq(outErr), any());
 
     CacheHandle entry = cache.lookup(simpleSpawn, simplePolicy);
     assertThat(entry.hasResult()).isTrue();
     SpawnResult result = entry.getResult();
     // All other methods on RemoteActionCache have side effects, so we verify all of them.
-    verify(remoteCache).download(actionResult, execRoot, outErr);
+    verify(remoteCache).download(eq(actionResult), eq(execRoot), eq(outErr), any());
     verify(remoteCache, never())
         .upload(
             any(ActionKey.class),
@@ -507,7 +507,7 @@
             });
     doThrow(new CacheNotFoundException(digest, digestUtil))
         .when(remoteCache)
-        .download(actionResult, execRoot, outErr);
+        .download(eq(actionResult), eq(execRoot), eq(outErr), any());
 
     CacheHandle entry = cache.lookup(simpleSpawn, simplePolicy);
     assertThat(entry.hasResult()).isFalse();
@@ -586,7 +586,7 @@
     // assert
     assertThat(cacheHandle.hasResult()).isTrue();
     assertThat(cacheHandle.getResult().exitCode()).isEqualTo(0);
-    verify(remoteCache).downloadMinimal(any(), anyCollection(), any(), any(), any(), any());
+    verify(remoteCache).downloadMinimal(any(), anyCollection(), any(), any(), any(), any(), any());
   }
 
   @Test
@@ -609,7 +609,7 @@
 
     ActionResult success = ActionResult.newBuilder().setExitCode(0).build();
     when(remoteCache.getCachedActionResult(any())).thenReturn(success);
-    when(remoteCache.downloadMinimal(any(), anyCollection(), any(), any(), any(), any()))
+    when(remoteCache.downloadMinimal(any(), anyCollection(), any(), any(), any(), any(), any()))
         .thenThrow(downloadFailure);
 
     // act
@@ -617,7 +617,7 @@
 
     // assert
     assertThat(cacheHandle.hasResult()).isFalse();
-    verify(remoteCache).downloadMinimal(any(), anyCollection(), any(), any(), any(), any());
+    verify(remoteCache).downloadMinimal(any(), anyCollection(), any(), any(), any(), any(), any());
     assertThat(eventHandler.getEvents().size()).isEqualTo(1);
     Event evt = eventHandler.getEvents().get(0);
     assertThat(evt.getKind()).isEqualTo(EventKind.WARNING);
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java
index d1fb3de..5dd3713 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java
@@ -45,21 +45,15 @@
 import com.google.common.util.concurrent.SettableFuture;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.Artifact;
-import com.google.devtools.build.lib.actions.Artifact.ArtifactExpander;
-import com.google.devtools.build.lib.actions.Artifact.TreeFileArtifact;
-import com.google.devtools.build.lib.actions.ArtifactPathResolver;
 import com.google.devtools.build.lib.actions.ArtifactRoot;
 import com.google.devtools.build.lib.actions.CommandLines.ParamFileActionInput;
 import com.google.devtools.build.lib.actions.ExecutionRequirements;
-import com.google.devtools.build.lib.actions.FileArtifactValue.RemoteFileArtifactValue;
-import com.google.devtools.build.lib.actions.MetadataProvider;
 import com.google.devtools.build.lib.actions.ParameterFile.ParameterFileType;
 import com.google.devtools.build.lib.actions.ResourceSet;
 import com.google.devtools.build.lib.actions.SimpleSpawn;
 import com.google.devtools.build.lib.actions.Spawn;
 import com.google.devtools.build.lib.actions.SpawnResult;
 import com.google.devtools.build.lib.actions.SpawnResult.Status;
-import com.google.devtools.build.lib.actions.cache.MetadataInjector;
 import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
 import com.google.devtools.build.lib.clock.JavaClock;
 import com.google.devtools.build.lib.events.Event;
@@ -68,19 +62,17 @@
 import com.google.devtools.build.lib.events.StoredEventHandler;
 import com.google.devtools.build.lib.exec.ExecutionOptions;
 import com.google.devtools.build.lib.exec.SpawnExecException;
-import com.google.devtools.build.lib.exec.SpawnInputExpander;
 import com.google.devtools.build.lib.exec.SpawnRunner;
-import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus;
 import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
 import com.google.devtools.build.lib.exec.util.FakeOwner;
 import com.google.devtools.build.lib.remote.options.RemoteOptions;
 import com.google.devtools.build.lib.remote.options.RemoteOutputsMode;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
 import com.google.devtools.build.lib.remote.util.DigestUtil.ActionKey;
+import com.google.devtools.build.lib.remote.util.FakeSpawnExecutionContext;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.FileOutErr;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
-import com.google.devtools.build.lib.vfs.FileStatus;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
 import com.google.devtools.build.lib.vfs.Path;
@@ -91,10 +83,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
-import java.time.Duration;
 import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicReference;
 import javax.annotation.Nullable;
@@ -196,7 +185,8 @@
             /*outputs=*/ ImmutableList.<ActionInput>of(),
             ResourceSet.ZERO);
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     runner.exec(spawn, policy);
 
@@ -236,7 +226,8 @@
             /*outputs=*/ ImmutableList.<ActionInput>of(),
             ResourceSet.ZERO);
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     runner.exec(spawn, policy);
 
@@ -255,7 +246,8 @@
     RemoteSpawnRunner runner = spy(newSpawnRunnerWithoutExecutor());
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = Mockito.mock(SpawnResult.class);
     when(res.exitCode()).thenReturn(1);
@@ -289,7 +281,8 @@
     RemoteSpawnRunner runner = spy(newSpawnRunnerWithoutExecutor());
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult succeeded =
         new SpawnResult.Builder()
@@ -313,7 +306,7 @@
             any(),
             /* uploadLocalResults= */ eq(true));
     verify(cache).upload(any(), any(), any(), any(), any(), any());
-    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr));
+    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr), any());
   }
 
   @Test
@@ -332,7 +325,8 @@
             .build();
     when(executor.executeRemotely(any(ExecuteRequest.class))).thenReturn(succeeded);
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     runner.exec(spawn, policy);
 
@@ -355,7 +349,8 @@
     RemoteSpawnRunner runner = newSpawnRunnerWithoutExecutor(reporter);
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     when(cache.getCachedActionResult(any(ActionKey.class)))
         .thenThrow(new IOException("cache down"));
@@ -394,7 +389,8 @@
     RemoteSpawnRunner runner = newSpawnRunnerWithoutExecutor();
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     when(cache.getCachedActionResult(any(ActionKey.class))).thenReturn(null);
 
@@ -417,7 +413,8 @@
     RemoteSpawnRunner runner = newSpawnRunnerWithoutExecutor();
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     when(cache.getCachedActionResult(any(ActionKey.class))).thenThrow(new IOException());
 
@@ -440,7 +437,8 @@
     when(executor.executeRemotely(any(ExecuteRequest.class))).thenThrow(new IOException());
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     IOException err = new IOException("local execution error");
     when(localRunner.exec(eq(spawn), eq(policy))).thenThrow(err);
@@ -469,7 +467,8 @@
     when(cache.downloadFile(eq(logPath), eq(logDigest))).thenReturn(completed);
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.NON_ZERO_EXIT);
@@ -498,7 +497,8 @@
     when(cache.downloadFile(eq(logPath), eq(logDigest))).thenReturn(completed);
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.TIMEOUT);
@@ -521,13 +521,13 @@
                 .build());
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
-
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.NON_ZERO_EXIT);
 
     verify(executor).executeRemotely(any(ExecuteRequest.class));
-    verify(cache).download(eq(result), eq(execRoot), any(FileOutErr.class));
+    verify(cache).download(eq(result), eq(execRoot), any(FileOutErr.class), any());
     verify(cache, never()).downloadFile(any(Path.class), any(Digest.class));
   }
 
@@ -547,13 +547,14 @@
                 .build());
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.SUCCESS);
 
     verify(executor).executeRemotely(any(ExecuteRequest.class));
-    verify(cache).download(eq(result), eq(execRoot), any(FileOutErr.class));
+    verify(cache).download(eq(result), eq(execRoot), any(FileOutErr.class), any());
     verify(cache, never()).downloadFile(any(Path.class), any(Digest.class));
   }
 
@@ -568,15 +569,16 @@
     Exception downloadFailure = new CacheNotFoundException(Digest.getDefaultInstance(), digestUtil);
     doThrow(downloadFailure)
         .when(cache)
-        .download(eq(cachedResult), any(Path.class), any(FileOutErr.class));
+        .download(eq(cachedResult), any(Path.class), any(FileOutErr.class), any());
     ActionResult execResult = ActionResult.newBuilder().setExitCode(31).build();
     ExecuteResponse succeeded = ExecuteResponse.newBuilder().setResult(execResult).build();
     when(executor.executeRemotely(any(ExecuteRequest.class))).thenReturn(succeeded);
-    doNothing().when(cache).download(eq(execResult), any(Path.class), any(FileOutErr.class));
+    doNothing().when(cache).download(eq(execResult), any(Path.class), any(FileOutErr.class), any());
 
     Spawn spawn = newSimpleSpawn();
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.NON_ZERO_EXIT);
@@ -604,12 +606,13 @@
     Exception downloadFailure = new CacheNotFoundException(Digest.getDefaultInstance(), digestUtil);
     doThrow(downloadFailure)
         .when(cache)
-        .download(eq(cachedResult), any(Path.class), any(FileOutErr.class));
-    doNothing().when(cache).download(eq(execResult), any(Path.class), any(FileOutErr.class));
+        .download(eq(cachedResult), any(Path.class), any(FileOutErr.class), any());
+    doNothing().when(cache).download(eq(execResult), any(Path.class), any(FileOutErr.class), any());
 
     Spawn spawn = newSimpleSpawn();
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.NON_ZERO_EXIT);
@@ -647,13 +650,14 @@
 
     Spawn spawn = newSimpleSpawn();
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.TIMEOUT);
 
     verify(executor).executeRemotely(any(ExecuteRequest.class));
-    verify(cache).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class));
+    verify(cache).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class), any());
   }
 
   @Test
@@ -680,13 +684,14 @@
 
     Spawn spawn = newSimpleSpawn();
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.TIMEOUT);
 
     verify(executor).executeRemotely(any(ExecuteRequest.class));
-    verify(cache).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class));
+    verify(cache).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class), any());
     verify(localRunner, never()).exec(eq(spawn), eq(policy));
   }
 
@@ -706,14 +711,15 @@
 
     Spawn spawn = newSimpleSpawn();
 
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.NON_ZERO_EXIT);
     assertThat(res.exitCode()).isEqualTo(33);
 
     verify(executor).executeRemotely(any(ExecuteRequest.class));
-    verify(cache, never()).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class));
+    verify(cache, never()).download(eq(cachedResult), eq(execRoot), any(FileOutErr.class), any());
     verify(localRunner, never()).exec(eq(spawn), eq(policy));
   }
 
@@ -730,7 +736,8 @@
     when(executor.executeRemotely(any(ExecuteRequest.class))).thenThrow(new IOException("reasons"));
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnExecException e = assertThrows(SpawnExecException.class, () -> runner.exec(spawn, policy));
     assertThat(e.getSpawnResult().exitCode()).isEqualTo(ExitCode.REMOTE_ERROR.getNumericExitCode());
@@ -749,7 +756,8 @@
     when(cache.getCachedActionResult(any(ActionKey.class))).thenThrow(new IOException("reasons"));
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     SpawnExecException e = assertThrows(SpawnExecException.class, () -> runner.exec(spawn, policy));
     assertThat(e.getSpawnResult().exitCode()).isEqualTo(ExitCode.REMOTE_ERROR.getNumericExitCode());
@@ -797,7 +805,8 @@
             ImmutableList.of(input),
             /*outputs=*/ ImmutableList.<ActionInput>of(),
             ResourceSet.ZERO);
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
     SpawnResult res = runner.exec(spawn, policy);
     assertThat(res.status()).isEqualTo(Status.SUCCESS);
     Path paramFile = execRoot.getRelative("out/param_file");
@@ -842,7 +851,8 @@
     RemoteSpawnRunner runner = newSpawnRunner();
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    SpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     // act
     SpawnResult result = runner.exec(spawn, policy);
@@ -850,8 +860,9 @@
     assertThat(result.status()).isEqualTo(Status.SUCCESS);
 
     // assert
-    verify(cache).downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any());
-    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr));
+    verify(cache)
+        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any(), any());
+    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr), any());
   }
 
   @Test
@@ -866,7 +877,8 @@
     RemoteSpawnRunner runner = newSpawnRunner();
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     // act
     SpawnResult result = runner.exec(spawn, policy);
@@ -875,8 +887,9 @@
 
     // assert
     verify(executor).executeRemotely(any());
-    verify(cache).downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any());
-    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr));
+    verify(cache)
+        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any(), any());
+    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr), any());
   }
 
   @Test
@@ -887,21 +900,23 @@
     ActionResult succeededAction = ActionResult.newBuilder().setExitCode(0).build();
     when(cache.getCachedActionResult(any(ActionKey.class))).thenReturn(succeededAction);
     IOException downloadFailure = new IOException("downloadMinimal failed");
-    when(cache.downloadMinimal(any(), anyCollection(), any(), any(), any(), any()))
+    when(cache.downloadMinimal(any(), anyCollection(), any(), any(), any(), any(), any()))
         .thenThrow(downloadFailure);
 
     RemoteSpawnRunner runner = newSpawnRunner();
 
     Spawn spawn = newSimpleSpawn();
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     // act
     SpawnExecException e = assertThrows(SpawnExecException.class, () -> runner.exec(spawn, policy));
     assertThat(e.getMessage()).isEqualTo(downloadFailure.getMessage());
 
     // assert
-    verify(cache).downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any());
-    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr));
+    verify(cache)
+        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any(), any());
+    verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr), any());
   }
 
   @Test
@@ -920,7 +935,8 @@
     RemoteSpawnRunner runner = newSpawnRunner(ImmutableSet.of(topLevelOutput));
 
     Spawn spawn = newSimpleSpawn(topLevelOutput);
-    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+    FakeSpawnExecutionContext policy =
+        new FakeSpawnExecutionContext(spawn, fakeFileCache, execRoot, outErr);
 
     // act
     SpawnResult result = runner.exec(spawn, policy);
@@ -928,9 +944,9 @@
     assertThat(result.status()).isEqualTo(Status.SUCCESS);
 
     // assert
-    verify(cache).download(eq(succeededAction), any(Path.class), eq(outErr));
+    verify(cache).download(eq(succeededAction), any(Path.class), eq(outErr), any());
     verify(cache, never())
-        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any());
+        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any(), any());
   }
 
   private static Spawn newSimpleSpawn(Artifact... outputs) {
@@ -994,100 +1010,4 @@
         reporter,
         /* topLevelOutputs= */ ImmutableSet.of());
   }
-
-  // TODO(buchgr): Extract a common class to be used for testing.
-  class FakeSpawnExecutionContext implements SpawnExecutionContext {
-
-    private final ArtifactExpander artifactExpander = (artifact, output) -> output.add(artifact);
-
-    private final Spawn spawn;
-
-    FakeSpawnExecutionContext(Spawn spawn) {
-      this.spawn = spawn;
-    }
-
-    @Override
-    public int getId() {
-      return 0;
-    }
-
-    @Override
-    public void prefetchInputs() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public void lockOutputFiles() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public boolean speculating() {
-      return false;
-    }
-
-    @Override
-    public MetadataProvider getMetadataProvider() {
-      return fakeFileCache;
-    }
-
-    @Override
-    public ArtifactExpander getArtifactExpander() {
-      throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public Duration getTimeout() {
-      return Duration.ZERO;
-    }
-
-    @Override
-    public FileOutErr getFileOutErr() {
-      return outErr;
-    }
-
-    @Override
-    public SortedMap<PathFragment, ActionInput> getInputMapping(
-        boolean expandTreeArtifactsInRunfiles) throws IOException {
-      return new SpawnInputExpander(execRoot, /*strict*/ false)
-          .getInputMapping(
-              spawn, artifactExpander, ArtifactPathResolver.IDENTITY, fakeFileCache, true);
-    }
-
-    @Override
-    public void report(ProgressStatus state, String name) {
-      assertThat(state).isEqualTo(ProgressStatus.EXECUTING);
-    }
-
-    @Override
-    public MetadataInjector getMetadataInjector() {
-      return new MetadataInjector() {
-        @Override
-        public void injectRemoteFile(Artifact output, byte[] digest, long size, int locationIndex) {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void injectRemoteDirectory(
-            Artifact.SpecialArtifact output, Map<PathFragment, RemoteFileArtifactValue> children) {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void markOmitted(ActionInput output) {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void addExpandedTreeOutput(TreeFileArtifact output) {
-          throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void injectDigest(ActionInput output, FileStatus statNoFollow, byte[] digest) {
-          throw new UnsupportedOperationException();
-        }
-      };
-    }
-  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/remote/SimpleBlobStoreActionCacheTest.java b/src/test/java/com/google/devtools/build/lib/remote/SimpleBlobStoreActionCacheTest.java
index 5b7b0de..23ef7b9 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/SimpleBlobStoreActionCacheTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/SimpleBlobStoreActionCacheTest.java
@@ -145,7 +145,7 @@
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputFilesBuilder().setPath("b/empty").setDigest(emptyDigest);
     result.addOutputFilesBuilder().setPath("a/bar").setDigest(barDigest).setIsExecutable(true);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("b/empty"))).isEqualTo(emptyDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar"))).isEqualTo(barDigest);
@@ -177,7 +177,7 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/qux"))).isEqualTo(quxDigest);
@@ -195,7 +195,7 @@
 
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(execRoot.getRelative("a/bar").isDirectory()).isTrue();
   }
@@ -233,7 +233,7 @@
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest);
     result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/wobble/qux"))).isEqualTo(quxDigest);
@@ -277,7 +277,7 @@
     SimpleBlobStoreActionCache client = newClient(map);
     ActionResult.Builder result = ActionResult.newBuilder();
     result.addOutputDirectoriesBuilder().setPath("a/").setTreeDigest(treeDigest);
-    client.download(result.build(), execRoot, null);
+    client.download(result.build(), execRoot, null, /* outputFilesLocker= */ () -> {});
 
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/foo/file"))).isEqualTo(fileDigest);
     assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo/file"))).isEqualTo(fileDigest);
diff --git a/src/test/java/com/google/devtools/build/lib/remote/util/BUILD b/src/test/java/com/google/devtools/build/lib/remote/util/BUILD
index f4764ec..c378136 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/util/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/remote/util/BUILD
@@ -14,6 +14,8 @@
     name = "util",
     srcs = glob(["*.java"]),
     deps = [
+        "//src/main/java/com/google/devtools/build/lib:build-base",
+        "//src/main/java/com/google/devtools/build/lib:io",
         "//src/main/java/com/google/devtools/build/lib/actions",
         "//src/main/java/com/google/devtools/build/lib/remote",
         "//src/main/java/com/google/devtools/build/lib/vfs",
diff --git a/src/test/java/com/google/devtools/build/lib/remote/util/FakeSpawnExecutionContext.java b/src/test/java/com/google/devtools/build/lib/remote/util/FakeSpawnExecutionContext.java
new file mode 100644
index 0000000..82e43ab
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/remote/util/FakeSpawnExecutionContext.java
@@ -0,0 +1,147 @@
+// Copyright 2019 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.util;
+
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.Artifact.ArtifactExpander;
+import com.google.devtools.build.lib.actions.Artifact.TreeFileArtifact;
+import com.google.devtools.build.lib.actions.ArtifactPathResolver;
+import com.google.devtools.build.lib.actions.FileArtifactValue.RemoteFileArtifactValue;
+import com.google.devtools.build.lib.actions.MetadataProvider;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.cache.MetadataInjector;
+import com.google.devtools.build.lib.exec.SpawnInputExpander;
+import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus;
+import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
+import com.google.devtools.build.lib.util.io.FileOutErr;
+import com.google.devtools.build.lib.vfs.FileStatus;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.SortedMap;
+
+/** Execution context for tests */
+public class FakeSpawnExecutionContext implements SpawnExecutionContext {
+
+  private boolean lockOutputFilesCalled;
+
+  private void artifactExpander(Artifact artifact, Collection<? super Artifact> output) {
+    output.add(artifact);
+  }
+
+  private final Spawn spawn;
+  private final MetadataProvider metadataProvider;
+  private final Path execRoot;
+  private final FileOutErr outErr;
+
+  public FakeSpawnExecutionContext(
+      Spawn spawn, MetadataProvider metadataProvider, Path execRoot, FileOutErr outErr) {
+    this.spawn = spawn;
+    this.metadataProvider = metadataProvider;
+    this.execRoot = execRoot;
+    this.outErr = outErr;
+  }
+
+  public boolean isLockOutputFilesCalled() {
+    return lockOutputFilesCalled;
+  }
+
+  @Override
+  public int getId() {
+    return 0;
+  }
+
+  @Override
+  public void prefetchInputs() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void lockOutputFiles() {
+    lockOutputFilesCalled = true;
+  }
+
+  @Override
+  public boolean speculating() {
+    return false;
+  }
+
+  @Override
+  public MetadataProvider getMetadataProvider() {
+    return metadataProvider;
+  }
+
+  @Override
+  public ArtifactExpander getArtifactExpander() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Duration getTimeout() {
+    return Duration.ZERO;
+  }
+
+  @Override
+  public FileOutErr getFileOutErr() {
+    return outErr;
+  }
+
+  @Override
+  public SortedMap<PathFragment, ActionInput> getInputMapping(boolean expandTreeArtifactsInRunfiles)
+      throws IOException {
+    return new SpawnInputExpander(execRoot, /*strict*/ false)
+        .getInputMapping(
+            spawn, this::artifactExpander, ArtifactPathResolver.IDENTITY, metadataProvider, true);
+  }
+
+  @Override
+  public void report(ProgressStatus state, String name) {
+    // Intentionally left empty.
+  }
+
+  @Override
+  public MetadataInjector getMetadataInjector() {
+    return new MetadataInjector() {
+      @Override
+      public void injectRemoteFile(Artifact output, byte[] digest, long size, int locationIndex) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void injectRemoteDirectory(
+          Artifact.SpecialArtifact output, Map<PathFragment, RemoteFileArtifactValue> children) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void markOmitted(ActionInput output) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void addExpandedTreeOutput(TreeFileArtifact output) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void injectDigest(ActionInput output, FileStatus statNoFollow, byte[] digest) {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+}