Internal change

PiperOrigin-RevId: 476133368
Change-Id: I137993aaeb12ea646416315cd1d56c83074f37f7
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/ExecutionGraphModuleTest.java b/src/test/java/com/google/devtools/build/lib/runtime/ExecutionGraphModuleTest.java
new file mode 100644
index 0000000..f7ca8ea
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/runtime/ExecutionGraphModuleTest.java
@@ -0,0 +1,686 @@
+// Copyright 2022 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.runtime;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import com.github.luben.zstd.ZstdInputStream;
+import com.github.luben.zstd.ZstdOutputStream;
+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.Action;
+import com.google.devtools.build.lib.actions.ActionCompletionEvent;
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.ActionInputHelper;
+import com.google.devtools.build.lib.actions.ActionOwner;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactRoot;
+import com.google.devtools.build.lib.actions.ArtifactRoot.RootType;
+import com.google.devtools.build.lib.actions.ExecutionGraph;
+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.SpawnExecutedEvent;
+import com.google.devtools.build.lib.actions.SpawnMetrics;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import com.google.devtools.build.lib.actions.SpawnResult.Status;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.bugreport.BugReporter;
+import com.google.devtools.build.lib.buildtool.BuildResult;
+import com.google.devtools.build.lib.buildtool.BuildResult.BuildToolLogCollection;
+import com.google.devtools.build.lib.buildtool.buildevent.BuildCompleteEvent;
+import com.google.devtools.build.lib.clock.BlazeClock;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.collect.nestedset.Order;
+import com.google.devtools.build.lib.exec.util.FakeOwner;
+import com.google.devtools.build.lib.exec.util.SpawnBuilder;
+import com.google.devtools.build.lib.runtime.ExecutionGraphModule.ActionDumpWriter;
+import com.google.devtools.build.lib.runtime.ExecutionGraphModule.DependencyInfo;
+import com.google.devtools.build.lib.testutil.FoundationTestCase;
+import com.google.devtools.build.lib.util.OS;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.testing.junit.testparameterinjector.TestParameter;
+import com.google.testing.junit.testparameterinjector.TestParameterInjector;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+/** Unit tests for {@link ExecutionGraphModule}. */
+@RunWith(TestParameterInjector.class)
+public class ExecutionGraphModuleTest extends FoundationTestCase {
+  private ExecutionGraphModule module;
+  private ArtifactRoot artifactRoot;
+
+  @Before
+  public void createModule() {
+    module = new ExecutionGraphModule();
+  }
+
+  @Before
+  public final void initializeRoots() throws Exception {
+    artifactRoot = ArtifactRoot.asDerivedRoot(scratch.resolve("/"), RootType.Output, "output");
+  }
+
+  private static ImmutableList<ExecutionGraph.Node> parse(ByteArrayOutputStream buffer)
+      throws IOException {
+    byte[] data = buffer.toByteArray();
+    try (InputStream in = new ZstdInputStream(new ByteArrayInputStream(data))) {
+      ImmutableList.Builder<ExecutionGraph.Node> nodeListBuilder = new ImmutableList.Builder<>();
+      ExecutionGraph.Node node;
+      while ((node = ExecutionGraph.Node.parseDelimitedFrom(in)) != null) {
+        nodeListBuilder.add(node);
+      }
+      return nodeListBuilder.build();
+    }
+  }
+
+  @Test
+  public void testOneSpawn() throws IOException {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    UUID uuid = UUID.randomUUID();
+    ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+    Spawn spawn =
+        new SimpleSpawn(
+            new FakeOwnerWithPrimaryOutput(
+                "Mnemonic", "Progress message", "//foo", "output/foo/out"),
+            ImmutableList.of("cmd"),
+            ImmutableMap.of("env", "value"),
+            ImmutableMap.of("exec", "value"),
+            /* inputs= */ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+            /* outputs= */ ImmutableSet.of(ActionInputHelper.fromPath("output/foo/out")),
+            ResourceSet.ZERO);
+    SpawnResult result =
+        new SpawnResult.Builder()
+            .setRunnerName("local")
+            .setStatus(Status.SUCCESS)
+            .setExitCode(0)
+            .setSpawnMetrics(
+                SpawnMetrics.Builder.forLocalExec()
+                    .setTotalTime(Duration.ofMillis(1234L))
+                    .setExecutionWallTime(Duration.ofMillis(2345L))
+                    .setProcessOutputsTime(Duration.ofMillis(3456L))
+                    .build())
+            .build();
+    startLogging(eventBus, uuid, buffer, DependencyInfo.NONE);
+    Instant startTimeInstant = Instant.now();
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, result, startTimeInstant));
+    module.buildComplete(
+        new BuildCompleteEvent(new BuildResult(startTimeInstant.toEpochMilli() + 1000)));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes).hasSize(1);
+    assertThat(nodes.get(0).getTargetLabel()).isEqualTo("//foo:foo");
+    assertThat(nodes.get(0).getMnemonic()).isEqualTo("Mnemonic");
+    assertThat(nodes.get(0).getMetrics().getDurationMillis()).isEqualTo(1234L);
+    assertThat(nodes.get(0).getMetrics().getFetchMillis()).isEqualTo(0);
+    assertThat(nodes.get(0).getMetrics().getProcessOutputsMillis()).isEqualTo(3456);
+    assertThat(nodes.get(0).getMetrics().getStartTimestampMillis())
+        .isEqualTo(startTimeInstant.toEpochMilli());
+    assertThat(nodes.get(0).getIndex()).isEqualTo(0);
+    assertThat(nodes.get(0).getDependentIndexList()).isEmpty();
+  }
+
+  @Test
+  public void actionDepsWithThreeSpawns() throws IOException {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    UUID uuid = UUID.randomUUID();
+    ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+
+    ActionInput out1 = ActionInputHelper.fromPath("output/foo/out1");
+    ActionInput out2 = ActionInputHelper.fromPath("output/foo/out2");
+    ActionInput outTop = ActionInputHelper.fromPath("output/foo/out.top");
+
+    Spawn spawnOut1 =
+        new SimpleSpawn(
+            new FakeOwnerWithPrimaryOutput(
+                "Mnemonic", "Progress message", "//foo", out1.getExecPathString()),
+            ImmutableList.of("cmd"),
+            ImmutableMap.of("env", "value"),
+            ImmutableMap.of("exec", "value"),
+            /* inputs= */ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+            /* outputs= */ ImmutableSet.of(out1),
+            ResourceSet.ZERO);
+    Spawn spawnOut2 =
+        new SimpleSpawn(
+            new FakeOwnerWithPrimaryOutput(
+                "Mnemonic", "Progress message", "//foo", out2.getExecPathString()),
+            ImmutableList.of("cmd"),
+            ImmutableMap.of("env", "value"),
+            ImmutableMap.of("exec", "value"),
+            /* inputs= */ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+            /* outputs= */ ImmutableSet.of(out2),
+            ResourceSet.ZERO);
+    Spawn spawnTop =
+        new SimpleSpawn(
+            new FakeOwnerWithPrimaryOutput(
+                "Mnemonic", "Progress message", "//foo", outTop.getExecPathString()),
+            ImmutableList.of("cmd"),
+            ImmutableMap.of("env", "value"),
+            ImmutableMap.of("exec", "value"),
+            /* inputs= */ NestedSetBuilder.create(Order.COMPILE_ORDER, out1, out2),
+            /* outputs= */ ImmutableSet.of(outTop),
+            ResourceSet.ZERO);
+    SpawnResult result =
+        new SpawnResult.Builder()
+            .setRunnerName("local")
+            .setStatus(Status.SUCCESS)
+            .setExitCode(0)
+            .setSpawnMetrics(
+                SpawnMetrics.Builder.forLocalExec()
+                    .setTotalTime(Duration.ofMillis(1234L))
+                    .setExecutionWallTime(Duration.ofMillis(2345L))
+                    .setProcessOutputsTime(Duration.ofMillis(3456L))
+                    .build())
+            .build();
+    startLogging(eventBus, uuid, buffer, DependencyInfo.ALL);
+    Instant startTimeInstant = Instant.now();
+    module.spawnExecuted(new SpawnExecutedEvent(spawnOut1, result, startTimeInstant));
+    module.spawnExecuted(new SpawnExecutedEvent(spawnOut2, result, startTimeInstant));
+    module.spawnExecuted(new SpawnExecutedEvent(spawnTop, result, startTimeInstant));
+    module.buildComplete(
+        new BuildCompleteEvent(new BuildResult(startTimeInstant.plusMillis(1000).toEpochMilli())));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes).hasSize(3);
+
+    assertThat(nodes.get(0).getIndex()).isEqualTo(0);
+    assertThat(nodes.get(0).getDependentIndexList()).isEmpty();
+
+    assertThat(nodes.get(1).getIndex()).isEqualTo(1);
+    assertThat(nodes.get(1).getDependentIndexList()).isEmpty();
+
+    assertThat(nodes.get(2).getIndex()).isEqualTo(2);
+    assertThat(nodes.get(2).getDependentIndexList()).containsExactly(0, 1);
+  }
+
+  private enum FailingOutputStreamFactory {
+    CLOSE {
+      @Override
+      public ZstdOutputStream get() throws IOException {
+        return new ZstdOutputStream(OutputStream.nullOutputStream()) {
+          @Override
+          public synchronized void close() throws IOException {
+            throw new IOException("Simulated close failure");
+          }
+        };
+      }
+    },
+    /** Called from {@link com.google.protobuf.CodedOutputStream#flush}. */
+    WRITE {
+      @Override
+      public ZstdOutputStream get() throws IOException {
+        return new ZstdOutputStream(OutputStream.nullOutputStream()) {
+          @Override
+          public synchronized void write(byte[] b, int off, int len) throws IOException {
+            throw new IOException("oh no!");
+          }
+        };
+      }
+    };
+
+    abstract ZstdOutputStream get() throws IOException;
+  }
+
+  /** Regression test for b/218721483. */
+  @Test(timeout = 30_000)
+  public void failureInOutputDoesNotHang(
+      @TestParameter FailingOutputStreamFactory failingOutputStream) {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    UUID uuid = UUID.randomUUID();
+    ActionDumpWriter writer =
+        new ActionDumpWriter(
+            BugReporter.defaultInstance(),
+            /*localLockFreeOutputEnabled=*/ false,
+            OutputStream.nullOutputStream(),
+            uuid,
+            DependencyInfo.NONE,
+            -1) {
+          @Override
+          protected void updateLogs(BuildToolLogCollection logs) {}
+
+          @Override
+          protected ZstdOutputStream createCompressingOutputStream() throws IOException {
+            return failingOutputStream.get();
+          }
+        };
+    module.setWriter(writer);
+    eventBus.register(module);
+
+    Instant startTimeInstant = Instant.now();
+    eventBus.post(new BuildCompleteEvent(new BuildResult(startTimeInstant.toEpochMilli() + 1000)));
+  }
+
+  private void startLogging(
+      EventBus eventBus, UUID uuid, OutputStream buffer, DependencyInfo depType) {
+    startLogging(
+        eventBus,
+        BugReporter.defaultInstance(),
+        /*localLockFreeOutputEnabled=*/ false,
+        uuid,
+        buffer,
+        depType);
+  }
+
+  private void startLogging(
+      EventBus eventBus,
+      BugReporter bugReporter,
+      boolean localLockFreeOutputEnabled,
+      UUID uuid,
+      OutputStream buffer,
+      DependencyInfo depType) {
+    ActionDumpWriter writer =
+        new ActionDumpWriter(bugReporter, localLockFreeOutputEnabled, buffer, uuid, depType, -1) {
+          @Override
+          protected void updateLogs(BuildToolLogCollection logs) {}
+        };
+    module.setWriter(writer);
+    eventBus.register(module);
+  }
+
+  @Test
+  public void shutDownWithoutStartTolerated() {
+    eventBus.register(module);
+    Instant startTimeInstant = Instant.now();
+    // Doesn't crash.
+    eventBus.post(new BuildCompleteEvent(new BuildResult(startTimeInstant.toEpochMilli() + 1000)));
+  }
+
+  @Test
+  public void testSpawnWithNullOwnerLabel() throws IOException {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    UUID uuid = UUID.randomUUID();
+    ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+    Spawn spawn =
+        new SimpleSpawn(
+            new FakeOwnerWithPrimaryOutput(
+                "Mnemonic", "Progress message", "//unused:label", "output/foo/out") {
+              @Override
+              public ActionOwner getOwner() {
+                return ActionOwner.SYSTEM_ACTION_OWNER;
+              }
+            },
+            ImmutableList.of("cmd"),
+            ImmutableMap.of("env", "value"),
+            ImmutableMap.of("exec", "value"),
+            /* inputs= */ NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+            /* outputs= */ ImmutableSet.of(ActionInputHelper.fromPath("output/foo/out")),
+            ResourceSet.ZERO);
+    SpawnResult result =
+        new SpawnResult.Builder()
+            .setRunnerName("local")
+            .setStatus(Status.SUCCESS)
+            .setExitCode(0)
+            .setSpawnMetrics(
+                SpawnMetrics.Builder.forLocalExec()
+                    .setTotalTime(Duration.ofMillis(1234L))
+                    .setExecutionWallTime(Duration.ofMillis(2345L))
+                    .setProcessOutputsTime(Duration.ofMillis(3456L))
+                    .build())
+            .build();
+    startLogging(eventBus, uuid, buffer, DependencyInfo.NONE);
+    Instant startTimeInstant = Instant.now();
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, result, startTimeInstant));
+    module.buildComplete(
+        new BuildCompleteEvent(new BuildResult(startTimeInstant.toEpochMilli() + 1000)));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes).hasSize(1);
+    assertThat(nodes.get(0).getTargetLabel()).isEmpty();
+  }
+
+  @Test
+  public void spawnAndAction_withSameOutputs() throws Exception {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    var buffer = new ByteArrayOutputStream();
+    startLogging(eventBus, UUID.randomUUID(), buffer, DependencyInfo.ALL);
+    var options = new ExecutionGraphModule.ExecutionGraphOptions();
+    options.logMissedActions = true;
+    module.setOptions(options);
+
+    module.spawnExecuted(
+        new SpawnExecutedEvent(
+            new SpawnBuilder().withOwnerPrimaryOutput(createOutputArtifact("foo/out")).build(),
+            createRemoteSpawnResult(Duration.ofMillis(200)),
+            Instant.ofEpochMilli(100)));
+    module.actionComplete(
+        new ActionCompletionEvent(
+            0, new ActionsTestUtil.NullAction(createOutputArtifact("foo/out")), null));
+    module.buildComplete(new BuildCompleteEvent(new BuildResult(1000)));
+
+    assertThat(parse(buffer))
+        .containsExactly(
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(0)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(100)
+                        .setDurationMillis(200)
+                        .setOtherMillis(200))
+                .setRunner("remote")
+                .build());
+  }
+
+  @Test
+  public void spawnAndAction_withDifferentOutputs() throws Exception {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    var buffer = new ByteArrayOutputStream();
+    startLogging(eventBus, UUID.randomUUID(), buffer, DependencyInfo.ALL);
+    var options = new ExecutionGraphModule.ExecutionGraphOptions();
+    options.logMissedActions = true;
+    module.setOptions(options);
+    var nanosToMillis = BlazeClock.createNanosToMillisSinceEpochConverter();
+    module.setNanosToMillis(nanosToMillis);
+
+    module.spawnExecuted(
+        new SpawnExecutedEvent(
+            new SpawnBuilder().withOwnerPrimaryOutput(createOutputArtifact("foo/out")).build(),
+            createRemoteSpawnResult(Duration.ofMillis(200)),
+            Instant.ofEpochMilli(100)));
+    var action = new ActionsTestUtil.NullAction(createOutputArtifact("bar/out"));
+    module.actionComplete(new ActionCompletionEvent(0, action, null));
+    module.buildComplete(new BuildCompleteEvent(new BuildResult(1000)));
+
+    assertThat(parse(buffer))
+        .containsExactly(
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(0)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(100)
+                        .setDurationMillis(200)
+                        .setOtherMillis(200))
+                .setRunner("remote")
+                .build(),
+            executionGraphNodeBuilderForAction(action)
+                .setIndex(1)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(nanosToMillis.toEpochMillis(0)))
+                .build());
+  }
+
+  @Test
+  public void multipleSpawnsWithSameOutput_recordsBothSpawnsWithRetry() throws Exception {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    var buffer = new ByteArrayOutputStream();
+    startLogging(eventBus, UUID.randomUUID(), buffer, DependencyInfo.ALL);
+    SpawnResult localResult = createLocalSpawnResult(Duration.ofMillis(100));
+    SpawnResult remoteResult = createRemoteSpawnResult(Duration.ofMillis(200));
+    Spawn spawn =
+        new SpawnBuilder().withOwnerPrimaryOutput(createOutputArtifact("foo/out")).build();
+
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, localResult, Instant.EPOCH));
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, remoteResult, Instant.ofEpochMilli(100)));
+    module.buildComplete(new BuildCompleteEvent(new BuildResult(1000)));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes)
+        .containsExactly(
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(0)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(0)
+                        .setDurationMillis(100)
+                        .setOtherMillis(100))
+                .setRunner("local")
+                .build(),
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(1)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(100)
+                        .setDurationMillis(200)
+                        .setOtherMillis(200))
+                .setRunner("remote")
+                .setRetryOf(0)
+                .build())
+        .inOrder();
+  }
+
+  enum LocalLockFreeOutput {
+    LOCAL_LOCK_FREE_OUTPUT_ENABLED(/*optionValue=*/ true) {
+      @Override
+      void assertBugReport(BugReporter bugReporter) {
+        verify(bugReporter, never()).sendNonFatalBugReport(any());
+      }
+    },
+    LOCAL_LOCK_FREE_OUTPUT_DISABLED(/*optionValue=*/ false) {
+      @Override
+      void assertBugReport(BugReporter bugReporter) {
+        var captor = ArgumentCaptor.forClass(Exception.class);
+        verify(bugReporter).sendNonFatalBugReport(captor.capture());
+        assertThat(captor.getValue())
+            .hasMessageThat()
+            .contains("Multiple spawns produced 'output/foo/out' with overlapping execution time.");
+      }
+    };
+
+    LocalLockFreeOutput(boolean optionValue) {
+      this.optionValue = optionValue;
+    }
+
+    private final boolean optionValue;
+
+    abstract void assertBugReport(BugReporter bugReporter);
+  }
+
+  @Test
+  public void multipleSpawnsWithSameOutput_overlapping_recordsBothSpawnsWithoutRetry(
+      @TestParameter LocalLockFreeOutput localLockFreeOutput) throws Exception {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    var buffer = new ByteArrayOutputStream();
+    BugReporter bugReporter = mock(BugReporter.class);
+    startLogging(
+        eventBus,
+        bugReporter,
+        localLockFreeOutput.optionValue,
+        UUID.randomUUID(),
+        buffer,
+        DependencyInfo.ALL);
+    SpawnResult localResult = createLocalSpawnResult(Duration.ofMillis(100));
+    SpawnResult remoteResult = createRemoteSpawnResult(Duration.ofMillis(200));
+    Spawn spawn =
+        new SpawnBuilder().withOwnerPrimaryOutput(createOutputArtifact("foo/out")).build();
+
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, localResult, Instant.EPOCH));
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, remoteResult, Instant.ofEpochMilli(10)));
+    module.buildComplete(new BuildCompleteEvent(new BuildResult(1000)));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes)
+        .containsExactly(
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(0)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(0)
+                        .setDurationMillis(100)
+                        .setOtherMillis(100))
+                .setRunner("local")
+                .build(),
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(1)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(10)
+                        .setDurationMillis(200)
+                        .setOtherMillis(200))
+                .setRunner("remote")
+                .build())
+        .inOrder();
+    localLockFreeOutput.assertBugReport(bugReporter);
+  }
+
+  @Test
+  public void multipleSpawnsWithSameOutput_overlapping_ignoresSecondSpawnForDependencies()
+      throws Exception {
+    // zstd is broken on Windows: https://github.com/bazelbuild/bazel/issues/16041
+    assumeTrue(OS.getCurrent() != OS.WINDOWS);
+
+    var buffer = new ByteArrayOutputStream();
+    startLogging(
+        eventBus,
+        BugReporter.defaultInstance(),
+        /*localLockFreeOutputEnabled=*/ true,
+        UUID.randomUUID(),
+        buffer,
+        DependencyInfo.ALL);
+    SpawnResult localResult = createLocalSpawnResult(Duration.ofMillis(100));
+    SpawnResult remoteResult = createRemoteSpawnResult(Duration.ofMillis(200));
+    Artifact input = createOutputArtifact("foo/input");
+    Spawn spawn = new SpawnBuilder().withOwnerPrimaryOutput(input).build();
+    Spawn dependentSpawn =
+        new SpawnBuilder()
+            .withOwnerPrimaryOutput(createOutputArtifact("foo/output"))
+            .withInput(input)
+            .build();
+    SpawnResult dependentResult = createRemoteSpawnResult(Duration.ofMillis(300));
+
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, localResult, Instant.EPOCH));
+    module.spawnExecuted(new SpawnExecutedEvent(spawn, remoteResult, Instant.ofEpochMilli(10)));
+    module.spawnExecuted(
+        new SpawnExecutedEvent(dependentSpawn, dependentResult, Instant.ofEpochMilli(300)));
+    module.buildComplete(new BuildCompleteEvent(new BuildResult(1000)));
+
+    ImmutableList<ExecutionGraph.Node> nodes = parse(buffer);
+    assertThat(nodes)
+        .containsExactly(
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(0)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(0)
+                        .setDurationMillis(100)
+                        .setOtherMillis(100))
+                .setRunner("local")
+                .build(),
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(1)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(10)
+                        .setDurationMillis(200)
+                        .setOtherMillis(200))
+                .setRunner("remote")
+                .build(),
+            executionGraphNodeBuilderForSpawnBuilderSpawn()
+                .setIndex(2)
+                .setMetrics(
+                    ExecutionGraph.Metrics.newBuilder()
+                        .setStartTimestampMillis(300)
+                        .setDurationMillis(300)
+                        .setOtherMillis(300))
+                .setRunner("remote")
+                .addDependentIndex(0)
+                .build())
+        .inOrder();
+  }
+
+  private class FakeOwnerWithPrimaryOutput extends FakeOwner {
+
+    private final String primaryOutput;
+
+    public FakeOwnerWithPrimaryOutput(
+        String mnemonic, String progressMessage, String ownerLabel, String primaryOutput) {
+      super(mnemonic, progressMessage, ownerLabel);
+      this.primaryOutput = primaryOutput;
+    }
+
+    @Override
+    public Artifact getPrimaryOutput() {
+      return ActionsTestUtil.createArtifactWithExecPath(
+          artifactRoot, PathFragment.create(primaryOutput));
+    }
+  }
+
+  private Artifact createOutputArtifact(String rootRelativePath) {
+    return ActionsTestUtil.createArtifactWithExecPath(
+        artifactRoot, artifactRoot.getExecPath().getRelative(rootRelativePath));
+  }
+
+  private SpawnResult createLocalSpawnResult(Duration totalTime) {
+    return new SpawnResult.Builder()
+        .setRunnerName("local")
+        .setStatus(Status.SUCCESS)
+        .setExitCode(0)
+        .setSpawnMetrics(SpawnMetrics.Builder.forLocalExec().setTotalTime(totalTime).build())
+        .build();
+  }
+
+  private SpawnResult createRemoteSpawnResult(Duration totalTime) {
+    return new SpawnResult.Builder()
+        .setRunnerName("remote")
+        .setStatus(Status.SUCCESS)
+        .setExitCode(0)
+        .setSpawnMetrics(SpawnMetrics.Builder.forRemoteExec().setTotalTime(totalTime).build())
+        .build();
+  }
+
+  /**
+   * Creates a {@link ExecutionGraph.Node.Builder} with pre-populated defaults for spawns created
+   * using {@link SpawnBuilder}.
+   */
+  private ExecutionGraph.Node.Builder executionGraphNodeBuilderForSpawnBuilderSpawn() {
+    return ExecutionGraph.Node.newBuilder()
+        .setDescription("action 'progress message'")
+        .setTargetLabel("//dummy:label")
+        .setMnemonic("Mnemonic")
+        // This comes from SpawnResult.Builder, which defaults to an empty string.
+        .setRunnerSubtype("");
+  }
+
+  /**
+   * Creates a {@link ExecutionGraph.Node.Builder} with pre-populated defaults for action events.
+   */
+  private ExecutionGraph.Node.Builder executionGraphNodeBuilderForAction(Action action) {
+    return ExecutionGraph.Node.newBuilder()
+        .setDescription(action.prettyPrint())
+        .setTargetLabel(action.getOwner().getLabel().toString())
+        .setMnemonic(action.getMnemonic());
+  }
+}