Adding stable sorting for execlog

RELNOTES: Introducing --execution_log_binary_file and --execution_log_json_file that output a stable sorted execution log. They will offer a stable replacement to --experimental_execution_log_file.
PiperOrigin-RevId: 243808737
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index 34a6a8d..1c1bc6d 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -140,6 +140,7 @@
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//third_party:guava",
         "//third_party/protobuf:protobuf_java",
+        "//third_party/protobuf:protobuf_java_util",
     ],
 )
 
@@ -867,6 +868,7 @@
         ":runtime",
         ":util",
         "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/bazel/execlog:stable_sort",
         "//src/main/java/com/google/devtools/build/lib/shell",
         "//src/main/java/com/google/devtools/build/lib/skyframe/serialization/autocodec",
         "//src/main/java/com/google/devtools/build/lib/vfs",
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java b/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java
index ab03ba8..55779ab 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.devtools.build.lib.bazel;
 
+import com.google.devtools.build.lib.bazel.execlog.StableSort;
 import com.google.devtools.build.lib.buildtool.BuildRequest;
 import com.google.devtools.build.lib.events.Event;
 import com.google.devtools.build.lib.exec.ExecutionOptions;
@@ -23,46 +24,136 @@
 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;
+import com.google.devtools.build.lib.util.io.MessageOutputStreamWrapper.BinaryOutputStreamWrapper;
+import com.google.devtools.build.lib.util.io.MessageOutputStreamWrapper.JsonOutputStreamWrapper;
+import com.google.devtools.build.lib.util.io.MessageOutputStreamWrapper.MessageOutputStreamCollection;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 
 /**
  * Module providing on-demand spawn logging.
  */
 public final class SpawnLogModule extends BlazeModule {
+  /**
+   * SpawnLogContext will log to a temporary file as the execution is being performed. rawOutput is
+   * the path to that temporary file.
+   */
   private SpawnLogContext spawnLogContext;
 
+  private Path rawOutput;
+
+  /**
+   * After the execution is done, the temporary file contents will be sorted and logged as the user
+   * requested, to binary and/or json files. We will open the streams at the beginning of the
+   * command so that any errors (e.g., unwritable location) will be surfaced before the execution
+   * begins.
+   */
+  private MessageOutputStreamCollection outputStreams;
+
+  private CommandEnvironment env;
+
+  private void clear() {
+    spawnLogContext = null;
+    outputStreams = new MessageOutputStreamCollection();
+    rawOutput = null;
+    env = null;
+  }
+
+  private void initOutputs(CommandEnvironment env) throws IOException {
+    clear();
+    this.env = env;
+
+    ExecutionOptions executionOptions = env.getOptions().getOptions(ExecutionOptions.class);
+    if (executionOptions == null) {
+      return;
+    }
+    FileSystem fileSystem = env.getRuntime().getFileSystem();
+    Path workingDirectory = env.getWorkingDirectory();
+
+    if (executionOptions.executionLogBinaryFile != null) {
+      outputStreams.addStream(
+          new BinaryOutputStreamWrapper(
+              workingDirectory
+                  .getRelative(executionOptions.executionLogBinaryFile)
+                  .getOutputStream()));
+    }
+
+    if (executionOptions.executionLogJsonFile != null) {
+      outputStreams.addStream(
+          new JsonOutputStreamWrapper(
+              workingDirectory
+                  .getRelative(executionOptions.executionLogJsonFile)
+                  .getOutputStream()));
+    }
+
+    AsynchronousFileOutputStream outStream = null;
+    if (executionOptions.executionLogFile != null) {
+      rawOutput = env.getRuntime().getFileSystem().getPath(executionOptions.executionLogFile);
+      outStream =
+          new AsynchronousFileOutputStream(
+              workingDirectory.getRelative(executionOptions.executionLogFile));
+    } else if (!outputStreams.isEmpty()) {
+      // Execution log requested but raw log file not specified
+      File file = File.createTempFile("exec", ".log");
+      rawOutput = fileSystem.getPath(file.getAbsolutePath());
+      outStream = new AsynchronousFileOutputStream(rawOutput);
+    }
+
+    if (outStream == null) {
+      // No logging needed
+      clear();
+      return;
+    }
+
+    spawnLogContext = new SpawnLogContext(env.getExecRoot(), outStream);
+  }
+
   @Override
   public void executorInit(CommandEnvironment env, BuildRequest request, ExecutorBuilder builder) {
     env.getEventBus().register(this);
-    ExecutionOptions executionOptions = env.getOptions().getOptions(ExecutionOptions.class);
-    if (executionOptions != null
-        && executionOptions.executionLogFile != null
-        && !executionOptions.executionLogFile.isEmpty()) {
-      try {
-        spawnLogContext = new SpawnLogContext(
-            env.getExecRoot(),
-            new AsynchronousFileOutputStream(executionOptions.executionLogFile));
-      } catch (IOException e) {
-        env.getReporter().handle(Event.error(e.getMessage()));
-        env.getBlazeModuleEnvironment().exit(new AbruptExitException(
-            "Error found creating SpawnLogContext", ExitCode.COMMAND_LINE_ERROR));
-      }
+
+    try {
+      initOutputs(env);
+    } catch (IOException e) {
+      env.getReporter().handle(Event.error(e.getMessage()));
+      env.getBlazeModuleEnvironment()
+          .exit(
+              new AbruptExitException(
+                  "Error initializing execution log", ExitCode.COMMAND_LINE_ERROR, e));
+    }
+
+    if (spawnLogContext != null) {
       builder.addActionContext(spawnLogContext);
       builder.addStrategyByContext(SpawnLogContext.class, "");
-    } else {
-      spawnLogContext = null;
     }
   }
 
   @Override
-  public void afterCommand() {
+  public void afterCommand() throws AbruptExitException {
+    boolean done = false;
     if (spawnLogContext != null) {
       try {
         spawnLogContext.close();
+        if (!outputStreams.isEmpty()) {
+          InputStream in = rawOutput.getInputStream();
+          StableSort.stableSort(in, outputStreams);
+          outputStreams.close();
+        }
+        done = true;
       } catch (IOException e) {
-        throw new RuntimeException(e);
+        throw new AbruptExitException(ExitCode.LOCAL_ENVIRONMENTAL_ERROR, e);
       } finally {
-        spawnLogContext = null;
+        if (!done && !outputStreams.isEmpty()) {
+          env.getReporter()
+              .handle(
+                  Event.warn(
+                      "Execution log might not have been populated. Raw execution log is at "
+                          + rawOutput));
+        }
+        clear();
       }
     }
   }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/BUILD b/src/main/java/com/google/devtools/build/lib/bazel/debug/BUILD
index 59f1202..8cb3158 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/BUILD
@@ -57,6 +57,7 @@
         "//src/main/java/com/google/devtools/build/lib:io",
         "//src/main/java/com/google/devtools/build/lib:runtime",
         "//src/main/java/com/google/devtools/build/lib:util",
+        "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
         "//src/main/java/com/google/devtools/common/options",
         "//third_party:guava",
     ],
@@ -66,6 +67,8 @@
     name = "debugging-options",
     srcs = ["DebuggingOptions.java"],
     deps = [
+        "//src/main/java/com/google/devtools/build/lib:util",
+        "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
         "//src/main/java/com/google/devtools/common/options",
     ],
 )
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/DebuggingOptions.java b/src/main/java/com/google/devtools/build/lib/bazel/debug/DebuggingOptions.java
index 4bed8b0..db2fef8 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/DebuggingOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/DebuggingOptions.java
@@ -14,6 +14,8 @@
 
 package com.google.devtools.build.lib.bazel.debug;
 
+import com.google.devtools.build.lib.util.OptionsUtils;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.Option;
 import com.google.devtools.common.options.OptionDocumentationCategory;
 import com.google.devtools.common.options.OptionEffectTag;
@@ -27,7 +29,8 @@
       category = "verbosity",
       documentationCategory = OptionDocumentationCategory.LOGGING,
       effectTags = {OptionEffectTag.UNKNOWN},
+      converter = OptionsUtils.PathFragmentConverter.class,
       help =
           "Log certain Workspace Rules events into this file as delimited WorkspaceEvent protos.")
-  public String workspaceRulesLogFile;
+  public PathFragment workspaceRulesLogFile;
 }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleModule.java b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleModule.java
index 9efbcf6..90f6da2 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleModule.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleModule.java
@@ -23,6 +23,7 @@
 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;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.OptionsBase;
 import java.io.IOException;
 
@@ -42,10 +43,12 @@
       return;
     }
 
-    String logFile = env.getOptions().getOptions(DebuggingOptions.class).workspaceRulesLogFile;
-    if (logFile != null && !logFile.isEmpty()) {
+    PathFragment logFile =
+        env.getOptions().getOptions(DebuggingOptions.class).workspaceRulesLogFile;
+    if (logFile != null) {
       try {
-        outFileStream = new AsynchronousFileOutputStream(logFile);
+        outFileStream =
+            new AsynchronousFileOutputStream(env.getWorkingDirectory().getRelative(logFile));
       } catch (IOException e) {
         env.getReporter().handle(Event.error(e.getMessage()));
         env.getBlazeModuleEnvironment()
diff --git a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
index 3247b924..843178d 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
@@ -436,14 +436,36 @@
   public boolean statsSummary;
 
   @Option(
-    name = "experimental_execution_log_file",
-    defaultValue = "",
-    category = "verbosity",
-    documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-    effectTags = {OptionEffectTag.UNKNOWN},
-    help = "Log the executed spawns into this file as delimited Spawn protos."
-  )
-  public String executionLogFile;
+      name = "experimental_execution_log_file",
+      defaultValue = "null",
+      category = "verbosity",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      converter = OptionsUtils.PathFragmentConverter.class,
+      help = "Log the executed spawns into this file as delimited Spawn protos.")
+  public PathFragment executionLogFile;
+
+  @Option(
+      name = "execution_log_binary_file",
+      defaultValue = "null",
+      category = "verbosity",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      converter = OptionsUtils.PathFragmentConverter.class,
+      help = "Log the executed spawns into this file as delimited Spawn protos.")
+  public PathFragment executionLogBinaryFile;
+
+  @Option(
+      name = "execution_log_json_file",
+      defaultValue = "null",
+      category = "verbosity",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      converter = OptionsUtils.PathFragmentConverter.class,
+      help =
+          "Log the executed spawns into this file as json representation of the delimited Spawn"
+              + " protos.")
+  public PathFragment executionLogJsonFile;
 
   @Option(
       name = "experimental_split_xml_generation",
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 9420d8c..894b767 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
@@ -174,8 +174,10 @@
 
     try {
       List<ClientInterceptor> interceptors = new ArrayList<>();
-      if (!remoteOptions.experimentalRemoteGrpcLog.isEmpty()) {
-        rpcLogFile = new AsynchronousFileOutputStream(remoteOptions.experimentalRemoteGrpcLog);
+      if (remoteOptions.experimentalRemoteGrpcLog != null) {
+        rpcLogFile =
+            new AsynchronousFileOutputStream(
+                env.getWorkingDirectory().getRelative(remoteOptions.experimentalRemoteGrpcLog));
         interceptors.add(new LoggingInterceptor(rpcLogFile, env.getRuntime().getClock()));
       }
 
diff --git a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java
index 961b108..755b6e5 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/options/RemoteOptions.java
@@ -205,9 +205,10 @@
 
   @Option(
       name = "experimental_remote_grpc_log",
-      defaultValue = "",
+      defaultValue = "null",
       category = "remote",
       documentationCategory = OptionDocumentationCategory.REMOTE,
+      converter = OptionsUtils.PathFragmentConverter.class,
       effectTags = {OptionEffectTag.UNKNOWN},
       help =
           "If specified, a path to a file to log gRPC call related details. This log consists of a"
@@ -216,7 +217,7 @@
               + "protobufs with each message prefixed by a varint denoting the size of the"
               + " following serialized protobuf message, as performed by the method "
               + "LogEntry.writeDelimitedTo(OutputStream).")
-  public String experimentalRemoteGrpcLog;
+  public PathFragment experimentalRemoteGrpcLog;
 
   @Option(
       name = "incompatible_remote_symlinks",
diff --git a/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java b/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
index 172750b..1482e54 100644
--- a/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
+++ b/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
@@ -22,6 +22,7 @@
 import com.google.common.util.concurrent.SettableFuture;
 import com.google.common.util.concurrent.Uninterruptibles;
 import com.google.devtools.build.lib.concurrent.ThreadSafety;
+import com.google.devtools.build.lib.vfs.Path;
 import com.google.protobuf.CodedOutputStream;
 import com.google.protobuf.Message;
 import com.google.protobuf.MessageLite;
@@ -29,8 +30,6 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.nio.file.Files;
-import java.nio.file.Paths;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.LinkedBlockingDeque;
@@ -50,11 +49,11 @@
   // To store any exception raised from the writes.
   private final AtomicReference<Throwable> exception = new AtomicReference<>();
 
-  public AsynchronousFileOutputStream(String filename) throws IOException {
+  public AsynchronousFileOutputStream(Path path) throws IOException {
     this(
-        filename,
+        path.toString(),
         new BufferedOutputStream( // Use a buffer of 100 kByte, scientifically chosen at random.
-            Files.newOutputStream(Paths.get(filename)), 100000));
+            path.getOutputStream(), 100000));
   }
 
   @VisibleForTesting
diff --git a/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStreamWrapper.java b/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStreamWrapper.java
new file mode 100644
index 0000000..58aea9c
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStreamWrapper.java
@@ -0,0 +1,107 @@
+// 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.util.io;
+
+import com.google.common.base.Preconditions;
+import com.google.protobuf.Message;
+import com.google.protobuf.util.JsonFormat;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+
+/** Creating a MessageOutputStream from an OutputStream */
+public class MessageOutputStreamWrapper {
+  /** Outputs the messages in binary format */
+  public static class BinaryOutputStreamWrapper implements MessageOutputStream {
+    private final OutputStream stream;
+
+    public BinaryOutputStreamWrapper(OutputStream stream) {
+      this.stream = Preconditions.checkNotNull(stream);
+    }
+
+    @Override
+    public void write(Message m) throws IOException {
+      Preconditions.checkNotNull(m);
+      m.writeDelimitedTo(stream);
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  /** Outputs the messages in JSON text format */
+  public static class JsonOutputStreamWrapper implements MessageOutputStream {
+    private final OutputStream stream;
+    private final JsonFormat.Printer printer = JsonFormat.printer().includingDefaultValueFields();
+
+    public JsonOutputStreamWrapper(OutputStream stream) {
+      Preconditions.checkNotNull(stream);
+      this.stream = stream;
+    }
+
+    @Override
+    public void write(Message m) throws IOException {
+      Preconditions.checkNotNull(m);
+      stream.write(printer.print(m).getBytes(StandardCharsets.UTF_8));
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  /** Outputs the messages in JSON text format */
+  public static class MessageOutputStreamCollection implements MessageOutputStream {
+    private final ArrayList<MessageOutputStream> streams = new ArrayList<>();
+
+    public boolean isEmpty() {
+      return streams.isEmpty();
+    }
+
+    public void addStream(MessageOutputStream m) {
+      streams.add(m);
+    }
+
+    @Override
+    public void write(Message m) throws IOException {
+      for (MessageOutputStream stream : streams) {
+        stream.write(m);
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      IOException firstException = null;
+      for (MessageOutputStream stream : streams) {
+        try {
+          stream.close();
+        } catch (IOException e) {
+          if (firstException == null) {
+            firstException = e;
+          } else {
+            firstException.addSuppressed(e);
+          }
+        }
+      }
+      if (firstException != null) {
+        throw firstException;
+      }
+    }
+  }
+}