remote: implement --experimental_remote_download_outputs=toplevel

This implements the second milestone of "Remote builds without the
Bytes" and introduces the "toplevel" option for the
--experimental_remote_download_outputs flag. This mode will only
download outputs of top level targets but not outputs of intermediate
actions.

On a build of Bazel against RBE I am still seeing a 50% speed up
compared to the "all" mode.

Progress towards #6862.

Closes #7984.

PiperOrigin-RevId: 243029119
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/AnalysisPhaseRunner.java b/src/main/java/com/google/devtools/build/lib/buildtool/AnalysisPhaseRunner.java
index 6af3eb8..a897ffc 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/AnalysisPhaseRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/AnalysisPhaseRunner.java
@@ -110,7 +110,12 @@
       }
 
       for (BlazeModule module : env.getRuntime().getBlazeModules()) {
-        module.afterAnalysis(env, request, buildOptions, analysisResult.getTargetsToBuild());
+        module.afterAnalysis(
+            env,
+            request,
+            buildOptions,
+            analysisResult.getTargetsToBuild(),
+            analysisResult.getAspects());
       }
 
       reportTargets(analysisResult);
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionContextProvider.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionContextProvider.java
index e73a44c..a64c20b 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionContextProvider.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionContextProvider.java
@@ -17,7 +17,9 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.actions.ActionContext;
+import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.ExecutionStrategy;
 import com.google.devtools.build.lib.actions.ExecutorInitException;
 import com.google.devtools.build.lib.exec.AbstractSpawnStrategy;
@@ -47,6 +49,7 @@
   private final DigestUtil digestUtil;
   @Nullable private final Path logDir;
   private final AtomicReference<SpawnRunner> fallbackRunner = new AtomicReference<>();
+  private ImmutableSet<Artifact> topLevelOutputs = ImmutableSet.of();
 
   private RemoteActionContextProvider(
       CommandEnvironment env,
@@ -99,7 +102,8 @@
               buildRequestId,
               commandId,
               env.getReporter(),
-              digestUtil);
+              digestUtil,
+              topLevelOutputs);
       return ImmutableList.of(spawnCache);
     } else {
       RemoteSpawnRunner spawnRunner =
@@ -116,7 +120,8 @@
               executor,
               retrier,
               digestUtil,
-              logDir);
+              logDir,
+              topLevelOutputs);
       return ImmutableList.of(new RemoteSpawnStrategy(env.getExecRoot(), spawnRunner));
     }
   }
@@ -166,6 +171,10 @@
     return cache;
   }
 
+  void setTopLevelOutputs(ImmutableSet<Artifact> topLevelOutputs) {
+    this.topLevelOutputs = Preconditions.checkNotNull(topLevelOutputs, "topLevelOutputs");
+  }
+
   @Override
   public void executionPhaseEnding() {
     if (cache != null) {
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
index 90c39fd..9420d8c 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
@@ -19,8 +19,13 @@
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.analysis.ConfiguredTarget;
+import com.google.devtools.build.lib.analysis.TopLevelArtifactHelper;
+import com.google.devtools.build.lib.analysis.config.BuildOptions;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
 import com.google.devtools.build.lib.authandtls.GoogleAuthUtils;
 import com.google.devtools.build.lib.buildeventstream.BuildEventArtifactUploader;
@@ -39,6 +44,7 @@
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
 import com.google.devtools.build.lib.runtime.ServerBuilder;
+import com.google.devtools.build.lib.skyframe.AspectValue;
 import com.google.devtools.build.lib.util.AbruptExitException;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.AsynchronousFileOutputStream;
@@ -313,6 +319,38 @@
     }
   }
 
+  @Override
+  public void afterAnalysis(
+      CommandEnvironment env,
+      BuildRequest request,
+      BuildOptions buildOptions,
+      Iterable<ConfiguredTarget> configuredTargets,
+      ImmutableSet<AspectValue> aspects) {
+    if (remoteOutputsMode != null && remoteOutputsMode.downloadToplevelOutputsOnly()) {
+      Preconditions.checkState(actionContextProvider != null, "actionContextProvider was null");
+      // TODO(buchgr): Consider only storing the action owners instead of the artifacts
+      // Collect all top level output artifacts of regular targets as well as aspects. This
+      // information is used by remote spawn runners to decide whether to download an artifact
+      // if --experimental_remote_download_outputs=toplevel is set
+      ImmutableSet.Builder<Artifact> topLevelOutputsBuilder = ImmutableSet.builder();
+      for (ConfiguredTarget configuredTarget : configuredTargets) {
+        topLevelOutputsBuilder.addAll(
+            TopLevelArtifactHelper.getAllArtifactsToBuild(
+                    configuredTarget, request.getTopLevelArtifactContext())
+                .getImportantArtifacts());
+      }
+
+      for (AspectValue aspect : aspects) {
+        topLevelOutputsBuilder.addAll(
+            TopLevelArtifactHelper.getAllArtifactsToBuild(
+                    aspect, request.getTopLevelArtifactContext())
+                .getImportantArtifacts());
+      }
+
+      actionContextProvider.setTopLevelOutputs(topLevelOutputsBuilder.build());
+    }
+  }
+
   private static void cleanAndCreateRemoteLogsDir(Path logDir) throws AbruptExitException {
     try {
       // Clean out old logs files.
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 3b1732d..4bd91ae 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
@@ -16,13 +16,18 @@
 import static com.google.common.base.Strings.isNullOrEmpty;
 import static com.google.devtools.build.lib.remote.util.Utils.createSpawnResult;
 import static com.google.devtools.build.lib.remote.util.Utils.getInMemoryOutputPath;
+import static com.google.devtools.build.lib.remote.util.Utils.hasTopLevelOutputs;
+import static com.google.devtools.build.lib.remote.util.Utils.shouldDownloadAllSpawnOutputs;
 
 import build.bazel.remote.execution.v2.Action;
 import build.bazel.remote.execution.v2.ActionResult;
 import build.bazel.remote.execution.v2.Command;
 import build.bazel.remote.execution.v2.Digest;
 import build.bazel.remote.execution.v2.Platform;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.ExecException;
 import com.google.devtools.build.lib.actions.ExecutionStrategy;
 import com.google.devtools.build.lib.actions.FileArtifactValue;
@@ -64,6 +69,7 @@
     name = {"remote-cache"},
     contextType = SpawnCache.class)
 final class RemoteSpawnCache implements SpawnCache {
+
   private final Path execRoot;
   private final RemoteOptions options;
 
@@ -77,6 +83,14 @@
 
   private final DigestUtil digestUtil;
 
+  /**
+   * Set of artifacts that are top level outputs
+   *
+   * <p>This set is empty unless {@link RemoteOutputsMode#TOPLEVEL} is specified. If so, this set is
+   * used to decide whether to download an output.
+   */
+  private final ImmutableSet<Artifact> topLevelOutputs;
+
   RemoteSpawnCache(
       Path execRoot,
       RemoteOptions options,
@@ -84,7 +98,8 @@
       String buildRequestId,
       String commandId,
       @Nullable Reporter cmdlineReporter,
-      DigestUtil digestUtil) {
+      DigestUtil digestUtil,
+      ImmutableSet<Artifact> topLevelOutputs) {
     this.execRoot = execRoot;
     this.options = options;
     this.remoteCache = remoteCache;
@@ -92,8 +107,10 @@
     this.buildRequestId = buildRequestId;
     this.commandId = commandId;
     this.digestUtil = digestUtil;
+    this.topLevelOutputs = Preconditions.checkNotNull(topLevelOutputs, "topLevelOutputs");
   }
 
+
   @Override
   public CacheHandle lookup(Spawn spawn, SpawnExecutionContext context)
       throws InterruptedException, IOException, ExecException {
@@ -138,31 +155,34 @@
         try (SilentCloseable c = prof.profile(ProfilerTask.REMOTE_CACHE_CHECK, "check cache hit")) {
           result = remoteCache.getCachedActionResult(actionKey);
         }
+        // In case the remote cache returned a failed action (exit code != 0) we treat it as a
+        // cache miss
         if (result != null && result.getExitCode() == 0) {
-          // In case if failed action returned (exit code != 0) we treat it as a cache miss
-          // Otherwise, we know that result exists.
-          PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
           InMemoryOutput inMemoryOutput = null;
-          switch (remoteOutputsMode) {
-            case MINIMAL:
-              try (SilentCloseable c =
-                  prof.profile(ProfilerTask.REMOTE_DOWNLOAD, "download outputs minimal")) {
-                inMemoryOutput =
-                    remoteCache.downloadMinimal(
-                        result,
-                        spawn.getOutputFiles(),
-                        inMemoryOutputPath,
-                        context.getFileOutErr(),
-                        execRoot,
-                        context.getMetadataInjector());
-              }
-              break;
-            case ALL:
-              try (SilentCloseable c =
-                  prof.profile(ProfilerTask.REMOTE_DOWNLOAD, "download outputs")) {
-                remoteCache.download(result, execRoot, context.getFileOutErr());
-              }
-              break;
+          boolean downloadOutputs =
+              shouldDownloadAllSpawnOutputs(
+                  remoteOutputsMode,
+                  /* exitCode = */ 0,
+                  hasTopLevelOutputs(spawn.getOutputFiles(), topLevelOutputs));
+          if (downloadOutputs) {
+            try (SilentCloseable c =
+                prof.profile(ProfilerTask.REMOTE_DOWNLOAD, "download outputs")) {
+              remoteCache.download(result, execRoot, context.getFileOutErr());
+            }
+          } else {
+            PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
+            // inject output metadata
+            try (SilentCloseable c =
+                prof.profile(ProfilerTask.REMOTE_DOWNLOAD, "download outputs minimal")) {
+              inMemoryOutput =
+                  remoteCache.downloadMinimal(
+                      result,
+                      spawn.getOutputFiles(),
+                      inMemoryOutputPath,
+                      context.getFileOutErr(),
+                      execRoot,
+                      context.getMetadataInjector());
+            }
           }
           SpawnResult spawnResult =
               createSpawnResult(
@@ -174,10 +194,10 @@
       } catch (IOException e) {
         String errorMsg = e.getMessage();
         if (isNullOrEmpty(errorMsg)) {
-            errorMsg = e.getClass().getSimpleName();
+          errorMsg = e.getClass().getSimpleName();
         }
-          errorMsg = "Reading from Remote Cache:\n" + errorMsg;
-          report(Event.warn(errorMsg));
+        errorMsg = "Reading from Remote Cache:\n" + errorMsg;
+        report(Event.warn(errorMsg));
       } finally {
         withMetadata.detach(previous);
       }
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 464f373..3e57b5c 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
@@ -20,6 +20,8 @@
 import static com.google.devtools.build.lib.remote.util.Utils.createSpawnResult;
 import static com.google.devtools.build.lib.remote.util.Utils.getFromFuture;
 import static com.google.devtools.build.lib.remote.util.Utils.getInMemoryOutputPath;
+import static com.google.devtools.build.lib.remote.util.Utils.hasTopLevelOutputs;
+import static com.google.devtools.build.lib.remote.util.Utils.shouldDownloadAllSpawnOutputs;
 
 import build.bazel.remote.execution.v2.Action;
 import build.bazel.remote.execution.v2.ActionResult;
@@ -35,6 +37,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.devtools.build.lib.actions.ActionInput;
@@ -109,6 +112,14 @@
   private final DigestUtil digestUtil;
   private final Path logDir;
 
+  /**
+   * Set of artifacts that are top level outputs
+   *
+   * <p>This set is empty unless {@link RemoteOutputsMode#TOPLEVEL} is specified. If so, this set is
+   * used to decide whether to download an output.
+   */
+  private final ImmutableSet<Artifact> topLevelOutputs;
+
   // Used to ensure that a warning is reported only once.
   private final AtomicBoolean warningReported = new AtomicBoolean();
 
@@ -125,7 +136,8 @@
       @Nullable GrpcRemoteExecutor remoteExecutor,
       @Nullable RemoteRetrier retrier,
       DigestUtil digestUtil,
-      Path logDir) {
+      Path logDir,
+      ImmutableSet<Artifact> topLevelOutputs) {
     this.execRoot = execRoot;
     this.remoteOptions = remoteOptions;
     this.executionOptions = executionOptions;
@@ -139,6 +151,7 @@
     this.retrier = retrier;
     this.digestUtil = digestUtil;
     this.logDir = logDir;
+    this.topLevelOutputs = Preconditions.checkNotNull(topLevelOutputs, "topLevelOutputs");
   }
 
   @Override
@@ -291,34 +304,29 @@
       SpawnExecutionContext context,
       RemoteOutputsMode remoteOutputsMode)
       throws ExecException, IOException, InterruptedException {
-    SpawnResult.Status actionStatus =
-        actionResult.getExitCode() == 0 ? Status.SUCCESS : Status.NON_ZERO_EXIT;
-    // In case the action failed, download all outputs. It might be helpful for debugging
-    // and there is no point in injecting output metadata of a failed action.
-    RemoteOutputsMode effectiveOutputsStrategy =
-        actionStatus == Status.SUCCESS ? remoteOutputsMode : RemoteOutputsMode.ALL;
-    PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
+    boolean downloadOutputs =
+        shouldDownloadAllSpawnOutputs(
+            remoteOutputsMode,
+            /* exitCode = */ actionResult.getExitCode(),
+            hasTopLevelOutputs(spawn.getOutputFiles(), topLevelOutputs));
     InMemoryOutput inMemoryOutput = null;
-    switch (effectiveOutputsStrategy) {
-      case MINIMAL:
-        try (SilentCloseable c =
-            Profiler.instance().profile(REMOTE_DOWNLOAD, "download outputs minimal")) {
-          inMemoryOutput =
-              remoteCache.downloadMinimal(
-                  actionResult,
-                  spawn.getOutputFiles(),
-                  inMemoryOutputPath,
-                  context.getFileOutErr(),
-                  execRoot,
-                  context.getMetadataInjector());
-        }
-        break;
-
-      case ALL:
-        try (SilentCloseable c = Profiler.instance().profile(REMOTE_DOWNLOAD, "download outputs")) {
-          remoteCache.download(actionResult, execRoot, context.getFileOutErr());
-        }
-        break;
+    if (downloadOutputs) {
+      try (SilentCloseable c = Profiler.instance().profile(REMOTE_DOWNLOAD, "download outputs")) {
+        remoteCache.download(actionResult, execRoot, context.getFileOutErr());
+      }
+    } else {
+      PathFragment inMemoryOutputPath = getInMemoryOutputPath(spawn);
+      try (SilentCloseable c =
+          Profiler.instance().profile(REMOTE_DOWNLOAD, "download outputs minimal")) {
+        inMemoryOutput =
+            remoteCache.downloadMinimal(
+                actionResult,
+                spawn.getOutputFiles(),
+                inMemoryOutputPath,
+                context.getFileOutErr(),
+                execRoot,
+                context.getMetadataInjector());
+      }
     }
     return createSpawnResult(actionResult.getExitCode(), cacheHit, getName(), inMemoryOutput);
   }
diff --git a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOutputsMode.java b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOutputsMode.java
index 632e67d..d1bb349 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOutputsMode.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOutputsMode.java
@@ -24,10 +24,21 @@
    * Generally don't download remote action outputs. The only outputs that are being downloaded are:
    * stdout, stderr and .d and .jdeps files for C++ and Java compilation actions.
    */
-  MINIMAL;
+  MINIMAL,
+
+  /**
+   * Downloads outputs of top level targets, but generally not intermediate outputs. The only
+   * intermediate outputs to be downloaded are .d and .jdeps files for C++ and Java compilation
+   * actions.
+   */
+  TOPLEVEL;
 
   /** Returns {@code true} iff action outputs should always be downloaded. */
   public boolean downloadAllOutputs() {
     return this == ALL;
   }
+
+  public boolean downloadToplevelOutputsOnly() {
+    return this == TOPLEVEL;
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/remote/util/BUILD b/src/main/java/com/google/devtools/build/lib/remote/util/BUILD
index 307858c..6974d85 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/util/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/util/BUILD
@@ -13,6 +13,7 @@
     deps = [
         "//src/main/java/com/google/devtools/build/lib:build-base",
         "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/remote/options",
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//third_party:guava",
         "//third_party/grpc:grpc-jar",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java b/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java
index 7fdbf61..1ab04da 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java
@@ -13,15 +13,20 @@
 // limitations under the License.
 package com.google.devtools.build.lib.remote.util;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.ExecutionRequirements;
 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.remote.options.RemoteOutputsMode;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.protobuf.ByteString;
 import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.concurrent.ExecutionException;
 import javax.annotation.Nullable;
 
@@ -78,6 +83,30 @@
     return builder.build();
   }
 
+  /** Returns {@code true} if all spawn outputs should be downloaded to disk. */
+  public static boolean shouldDownloadAllSpawnOutputs(
+      RemoteOutputsMode remoteOutputsMode, int exitCode, boolean hasTopLevelOutputs) {
+    return remoteOutputsMode.downloadAllOutputs()
+        ||
+        // In case the action failed, download all outputs. It might be helpful for debugging
+        // and there is no point in injecting output metadata of a failed action.
+        exitCode != 0
+        ||
+        // If one output of a spawn is a top level output then download all outputs. Spawns
+        // are typically structured in a way that either all or no outputs are top level and
+        // it's much simpler to implement under this assumption.
+        (remoteOutputsMode.downloadToplevelOutputsOnly() && hasTopLevelOutputs);
+  }
+
+  /** Returns {@code true} if outputs contains one or more top level outputs. */
+  public static boolean hasTopLevelOutputs(
+      Collection<? extends ActionInput> outputs, ImmutableSet<Artifact> topLevelOutputs) {
+    if (topLevelOutputs.isEmpty()) {
+      return false;
+    }
+    return !Collections.disjoint(outputs, topLevelOutputs);
+  }
+
   /** An in-memory output file. */
   public static final class InMemoryOutput {
     private final ActionInput output;
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/BlazeModule.java b/src/main/java/com/google/devtools/build/lib/runtime/BlazeModule.java
index dda1465..13ed9f9 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/BlazeModule.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/BlazeModule.java
@@ -15,6 +15,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.actions.ExecutorInitException;
 import com.google.devtools.build.lib.analysis.BlazeDirectories;
 import com.google.devtools.build.lib.analysis.BlazeVersionInfo;
@@ -30,6 +31,7 @@
 import com.google.devtools.build.lib.exec.ExecutorBuilder;
 import com.google.devtools.build.lib.packages.Package;
 import com.google.devtools.build.lib.packages.PackageFactory;
+import com.google.devtools.build.lib.skyframe.AspectValue;
 import com.google.devtools.build.lib.skyframe.PrecomputedValue;
 import com.google.devtools.build.lib.util.AbruptExitException;
 import com.google.devtools.build.lib.util.io.OutErr;
@@ -260,7 +262,8 @@
       CommandEnvironment env,
       BuildRequest request,
       BuildOptions buildOptions,
-      Iterable<ConfiguredTarget> configuredTargets)
+      Iterable<ConfiguredTarget> configuredTargets,
+      ImmutableSet<AspectValue> aspects)
       throws InterruptedException, ViewCreationFailedException {}
 
   /**
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 52ef53e..2f8f7da 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
@@ -306,7 +306,9 @@
             executor,
             RemoteModule.createExecuteRetrier(remoteOptions, retryService),
             DIGEST_UTIL,
-            logDir);
+            logDir,
+            /* topLevelOutputs= */ ImmutableSet.of());
+
     inputDigest = fakeFileCache.createScratchInput(simpleSpawn.getInputFiles().get(0), "xyz");
     command =
         Command.newBuilder()
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 8fd9016..3b59f98 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
@@ -31,6 +31,7 @@
 import build.bazel.remote.execution.v2.RequestMetadata;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.eventbus.EventBus;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
@@ -238,7 +239,9 @@
             "build-req-id",
             "command-id",
             reporter,
-            digestUtil);
+            digestUtil,
+            /* topLevelOutputs= */ ImmutableSet.of());
+
     fakeFileCache.createScratchInput(simpleSpawn.getInputFiles().get(0), "xyz");
   }
 
@@ -570,7 +573,8 @@
             "build-req-id",
             "command-id",
             reporter,
-            digestUtil);
+            digestUtil,
+            /* topLevelOutputs= */ ImmutableSet.of());
 
     ActionResult success = ActionResult.newBuilder().setExitCode(0).build();
     when(remoteCache.getCachedActionResult(any())).thenReturn(success);
@@ -597,7 +601,9 @@
             "build-req-id",
             "command-id",
             reporter,
-            digestUtil);
+            digestUtil,
+            /* topLevelOutputs= */ ImmutableSet.of());
+
     IOException downloadFailure = new IOException("downloadMinimal failed");
 
     ActionResult success = ActionResult.newBuilder().setExitCode(0).build();
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 7870c86..fed06d7 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
@@ -37,6 +37,7 @@
 import build.bazel.remote.execution.v2.Platform;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.eventbus.EventBus;
 import com.google.common.io.ByteStreams;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
@@ -47,6 +48,7 @@
 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;
@@ -794,7 +796,8 @@
             executor,
             retrier,
             digestUtil,
-            logDir);
+            logDir,
+            /* topLevelOutputs= */ ImmutableSet.of());
 
     ExecuteResponse succeeded =
         ExecuteResponse.newBuilder()
@@ -926,23 +929,63 @@
     verify(cache, never()).download(any(ActionResult.class), any(Path.class), eq(outErr));
   }
 
-  private static Spawn newSimpleSpawn() {
+  @Test
+  public void testDownloadTopLevel() throws Exception {
+    // arrange
+    RemoteOptions options = Options.getDefaults(RemoteOptions.class);
+    options.remoteOutputsMode = RemoteOutputsMode.TOPLEVEL;
+
+    ArtifactRoot outputRoot = ArtifactRoot.asDerivedRoot(execRoot, execRoot.getRelative("outs"));
+    Artifact topLevelOutput = new Artifact(outputRoot.getRoot().getRelative("foo.bin"), outputRoot);
+
+    ActionResult succeededAction = ActionResult.newBuilder().setExitCode(0).build();
+    when(cache.getCachedActionResult(any(ActionKey.class))).thenReturn(succeededAction);
+
+    RemoteSpawnRunner runner = newSpawnRunner(ImmutableSet.of(topLevelOutput));
+
+    Spawn spawn = newSimpleSpawn(topLevelOutput);
+    SpawnExecutionContext policy = new FakeSpawnExecutionContext(spawn);
+
+    // act
+    SpawnResult result = runner.exec(spawn, policy);
+    assertThat(result.exitCode()).isEqualTo(0);
+    assertThat(result.status()).isEqualTo(Status.SUCCESS);
+
+    // assert
+    verify(cache).download(eq(succeededAction), any(Path.class), eq(outErr));
+    verify(cache, never())
+        .downloadMinimal(eq(succeededAction), anyCollection(), any(), any(), any(), any());
+  }
+
+  private static Spawn newSimpleSpawn(Artifact... outputs) {
     return new SimpleSpawn(
         new FakeOwner("foo", "bar"),
         /*arguments=*/ ImmutableList.of(),
         /*environment=*/ ImmutableMap.of(),
         /*executionInfo=*/ ImmutableMap.of(),
         /*inputs=*/ ImmutableList.of(),
-        /*outputs=*/ ImmutableList.<ActionInput>of(),
+        /*outputs=*/ ImmutableList.copyOf(outputs),
         ResourceSet.ZERO);
   }
 
   private RemoteSpawnRunner newSpawnRunner() {
-    return newSpawnRunner(/* verboseFailures= */ false, executor, /* reporter= */ null);
+    return newSpawnRunner(
+        /* verboseFailures= */ false,
+        executor,
+        /* reporter= */ null,
+        /* topLevelOutputs= */ ImmutableSet.of());
+  }
+
+  private RemoteSpawnRunner newSpawnRunner(ImmutableSet<Artifact> topLevelOutputs) {
+    return newSpawnRunner(
+        /* verboseFailures= */ false, executor, /* reporter= */ null, topLevelOutputs);
   }
 
   private RemoteSpawnRunner newSpawnRunner(
-      boolean verboseFailures, @Nullable GrpcRemoteExecutor executor, @Nullable Reporter reporter) {
+      boolean verboseFailures,
+      @Nullable GrpcRemoteExecutor executor,
+      @Nullable Reporter reporter,
+      ImmutableSet<Artifact> topLevelOutputs) {
     return new RemoteSpawnRunner(
         execRoot,
         remoteOptions,
@@ -956,15 +999,24 @@
         executor,
         retrier,
         digestUtil,
-        logDir);
+        logDir,
+        topLevelOutputs);
   }
 
   private RemoteSpawnRunner newSpawnRunnerWithoutExecutor() {
-    return newSpawnRunner(/* verboseFailures= */ false, /* executor= */ null, /* reporter= */ null);
+    return newSpawnRunner(
+        /* verboseFailures= */ false,
+        /* executor= */ null,
+        /* reporter= */ null,
+        /* topLevelOutputs= */ ImmutableSet.of());
   }
 
   private RemoteSpawnRunner newSpawnRunnerWithoutExecutor(Reporter reporter) {
-    return newSpawnRunner(/* verboseFailures= */ false, /* executor= */ null, reporter);
+    return newSpawnRunner(
+        /* verboseFailures= */ false,
+        /* executor= */ null,
+        reporter,
+        /* topLevelOutputs= */ ImmutableSet.of());
   }
 
   // TODO(buchgr): Extract a common class to be used for testing.
diff --git a/src/test/shell/bazel/remote/remote_execution_test.sh b/src/test/shell/bazel/remote/remote_execution_test.sh
index 17c5ea2..f035647 100755
--- a/src/test/shell/bazel/remote/remote_execution_test.sh
+++ b/src/test/shell/bazel/remote/remote_execution_test.sh
@@ -938,8 +938,9 @@
 }
 
 function test_downloads_minimal_native_prefetch() {
-  # Test that when using --experimental_remote_outputs=minimal a remotely stored output that's an
-  # input to a native action (ctx.actions.expand_template) is staged lazily for action execution.
+  # Test that when using --experimental_remote_download_outputs=minimal a remotely stored output
+  # that's an input to a native action (ctx.actions.expand_template) is staged lazily for action
+  # execution.
   mkdir -p a
   cat > a/substitute_username.bzl <<'EOF'
 def _substitute_username_impl(ctx):
@@ -1000,6 +1001,59 @@
   || fail "Expected bazel-bin/a/template.txt to have been deleted again"
 }
 
+function test_downloads_toplevel() {
+  # Test that when using --experimental_remote_download_outputs=toplevel only the output of the
+  # toplevel target is being downloaded.
+  mkdir -p a
+  cat > a/BUILD <<'EOF'
+genrule(
+  name = "foo",
+  srcs = [],
+  outs = ["foo.txt"],
+  cmd = "echo \"foo\" > \"$@\"",
+)
+
+genrule(
+  name = "foobar",
+  srcs = [":foo"],
+  outs = ["foobar.txt"],
+  cmd = "cat $(location :foo) > \"$@\" && echo \"bar\" >> \"$@\"",
+)
+EOF
+
+  bazel build \
+    --genrule_strategy=remote \
+    --remote_executor=localhost:${worker_port} \
+    --experimental_inmemory_jdeps_files \
+    --experimental_inmemory_dotd_files \
+    --experimental_remote_download_outputs=toplevel \
+    //a:foobar || fail "Failed to build //a:foobar"
+
+  (! [[ -f bazel-bin/a/foo.txt ]]) \
+  || fail "Expected intermediate output bazel-bin/a/foo.txt to not be downloaded"
+
+  [[ -f bazel-bin/a/foobar.txt ]] \
+  || fail "Expected toplevel output bazel-bin/a/foobar.txt to be downloaded"
+
+
+  # Delete the file to test that the action is re-run
+  rm -f bazel-bin/a/foobar.txt
+
+  bazel build \
+    --genrule_strategy=remote \
+    --remote_executor=localhost:${worker_port} \
+    --experimental_inmemory_jdeps_files \
+    --experimental_inmemory_dotd_files \
+    --experimental_remote_download_outputs=toplevel \
+    //a:foobar >& $TEST_log || fail "Failed to build //a:foobar"
+
+  expect_log "1 process: 1 remote cache hit"
+
+  [[ -f bazel-bin/a/foobar.txt ]] \
+  || fail "Expected toplevel output bazel-bin/a/foobar.txt to be re-downloaded"
+
+
+}
 # TODO(alpha): Add a test that fails remote execution when remote worker
 # supports sandbox.