Add stats about cache hits and execution strategies to Bazel's UI.

Fixes: 2846

RELNOTES: Bazel now displays information about remote cache hits and execution strategies used in its UI after every build and test, and adds a corresponding line "process stats" to BuildToolLogs in BEP.
PiperOrigin-RevId: 191441770
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/BuildSummaryStatsModule.java b/src/main/java/com/google/devtools/build/lib/runtime/BuildSummaryStatsModule.java
index 927355d..4d2cdce 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/BuildSummaryStatsModule.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/BuildSummaryStatsModule.java
@@ -18,6 +18,7 @@
 import com.google.common.eventbus.EventBus;
 import com.google.common.eventbus.Subscribe;
 import com.google.devtools.build.lib.actions.ActionKeyContext;
+import com.google.devtools.build.lib.actions.ActionResultReceivedEvent;
 import com.google.devtools.build.lib.buildeventstream.BuildToolLogs;
 import com.google.devtools.build.lib.buildtool.BuildRequest;
 import com.google.devtools.build.lib.buildtool.buildevent.BuildCompleteEvent;
@@ -50,11 +51,14 @@
   private boolean enabled;
   private boolean discardActions;
 
+  private SpawnStats spawnStats;
+
   @Override
   public void beforeCommand(CommandEnvironment env) {
     this.reporter = env.getReporter();
     this.eventBus = env.getEventBus();
     this.actionKeyContext = env.getSkyframeExecutor().getActionKeyContext();
+    this.spawnStats = new SpawnStats();
     eventBus.register(this);
   }
 
@@ -63,6 +67,7 @@
     this.criticalPathComputer = null;
     this.eventBus = null;
     this.reporter = null;
+    this.spawnStats = null;
   }
 
   @Override
@@ -81,6 +86,11 @@
   }
 
   @Subscribe
+  public void actionResultReceived(ActionResultReceivedEvent event) {
+    spawnStats.countActionResult(event.getActionResult());
+  }
+
+  @Subscribe
   public void buildComplete(BuildCompleteEvent event) {
     try {
       // We might want to make this conditional on a flag; it can sometimes be a bit of a nuisance.
@@ -115,6 +125,11 @@
       }
 
       reporter.handle(Event.info(Joiner.on(", ").join(items)));
+
+      String spawnSummary = spawnStats.getSummary();
+      reporter.handle(Event.info(spawnSummary));
+      statistics.add(Pair.of("process stats", ByteString.copyFromUtf8(spawnSummary)));
+
       reporter.post(new BuildToolLogs(statistics, ImmutableList.of()));
     } finally {
       criticalPathComputer = null;
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/SpawnStats.java b/src/main/java/com/google/devtools/build/lib/runtime/SpawnStats.java
new file mode 100644
index 0000000..dcb68ca
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/runtime/SpawnStats.java
@@ -0,0 +1,106 @@
+// Copyright 2018 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 com.google.common.collect.ConcurrentHashMultiset;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multiset;
+import com.google.devtools.build.lib.actions.ActionResult;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import javax.annotation.concurrent.ThreadSafe;
+
+/** Collects results from SpawnResult. */
+@ThreadSafe
+class SpawnStats {
+  private final ConcurrentHashMultiset<String> runners = ConcurrentHashMultiset.create();
+  private static final ImmutableList<String> REPORT_FIRST = ImmutableList.of("remote cache hit");
+
+  public void countActionResult(ActionResult actionResult) {
+    for (SpawnResult r : actionResult.spawnResults()) {
+      countRunnerName(r.getRunnerName());
+    }
+  }
+
+  public void countRunnerName(String runner) {
+    runners.add(runner);
+  }
+
+  private static class ResultString {
+    StringBuilder result = new StringBuilder();
+    String firstRunner;
+    int spawnsCount = 0;
+    int runnersNum = 0;
+
+    public int spawnsCount() {
+      return spawnsCount;
+    }
+
+    public void add(String name, int count) {
+      spawnsCount += count;
+      runnersNum += 1;
+
+      if (runnersNum == 1) {
+        firstRunner = name;
+      }
+
+      if (result.length() > 0) {
+        result.append(", ");
+      }
+      result.append(count);
+      result.append(" ");
+      result.append(name);
+    }
+
+    @Override
+    public String toString() {
+      if (runnersNum == 0) {
+        return "";
+      }
+      if (runnersNum == 1) {
+        return ", " + firstRunner;
+      }
+      return ": " + result;
+    }
+  }
+
+  /*
+   * Returns a human-readable summary of spawns counted.
+   */
+  public String getSummary() {
+    ResultString result = new ResultString();
+
+    // First report cache results.
+    for (String s : REPORT_FIRST) {
+      int count = runners.setCount(s, 0);
+      if (count > 0) {
+        result.add(s, count);
+      }
+    }
+
+    // Sort the rest alphabetically
+    ArrayList<Multiset.Entry<String>> list = new ArrayList<>(runners.entrySet());
+    Collections.sort(list, Comparator.comparing(e -> e.getElement()));
+
+    for (Multiset.Entry<String> e : list) {
+      result.add(e.getElement(), e.getCount());
+    }
+
+    int total = result.spawnsCount();
+    return total + " process" + (total == 1 ? "" : "es") + result + ".";
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/SpawnStatsTest.java b/src/test/java/com/google/devtools/build/lib/runtime/SpawnStatsTest.java
new file mode 100644
index 0000000..968108d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/runtime/SpawnStatsTest.java
@@ -0,0 +1,160 @@
+// Copyright 2018 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 com.google.devtools.build.lib.actions.ActionResult;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import java.util.ArrayList;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Testing SpawnStats */
+@RunWith(JUnit4.class)
+public final class SpawnStatsTest {
+
+  SpawnStats stats;
+
+  @Before
+  public void setUp() {
+    stats = new SpawnStats();
+  }
+
+  @Test
+  public void emptySet() {
+    assertThat(stats.getSummary()).isEqualTo("0 processes.");
+  }
+
+  @Test
+  public void one() {
+    stats.countRunnerName("foo");
+    assertThat(stats.getSummary()).isEqualTo("1 process, foo.");
+  }
+
+  @Test
+  public void oneRemote() {
+    stats.countRunnerName("remote cache hit");
+    assertThat(stats.getSummary()).isEqualTo("1 process, remote cache hit.");
+  }
+
+  @Test
+  public void two() {
+    stats.countRunnerName("foo");
+    stats.countRunnerName("foo");
+    assertThat(stats.getSummary()).isEqualTo("2 processes, foo.");
+  }
+
+  @Test
+  public void order() {
+    stats.countRunnerName("a");
+    stats.countRunnerName("b");
+    stats.countRunnerName("b");
+    stats.countRunnerName("c");
+    stats.countRunnerName("c");
+    stats.countRunnerName("c");
+    assertThat(stats.getSummary()).isEqualTo("6 processes: 1 a, 2 b, 3 c.");
+  }
+
+  @Test
+  public void reverseOrder() {
+    stats.countRunnerName("a");
+    stats.countRunnerName("a");
+    stats.countRunnerName("a");
+    stats.countRunnerName("b");
+    stats.countRunnerName("b");
+    stats.countRunnerName("c");
+    assertThat(stats.getSummary()).isEqualTo("6 processes: 3 a, 2 b, 1 c.");
+  }
+
+  @Test
+  public void cacheFirst() {
+    stats.countRunnerName("a");
+    stats.countRunnerName("a");
+    stats.countRunnerName("a");
+    stats.countRunnerName("b");
+    stats.countRunnerName("remote cache hit");
+    stats.countRunnerName("b");
+    stats.countRunnerName("c");
+    assertThat(stats.getSummary()).isEqualTo("7 processes: 1 remote cache hit, 3 a, 2 b, 1 c.");
+  }
+
+  private final SpawnResult rA =
+      new SpawnResult.Builder().setStatus(SpawnResult.Status.SUCCESS).setRunnerName("abc").build();
+  private final SpawnResult rB =
+      new SpawnResult.Builder().setStatus(SpawnResult.Status.SUCCESS).setRunnerName("cde").build();
+
+  @Test
+  public void actionOneSpawn() {
+
+    ArrayList<SpawnResult> spawns = new ArrayList<>();
+    spawns.add(rA);
+
+    stats.countActionResult(ActionResult.create(spawns));
+    assertThat(stats.getSummary()).isEqualTo("1 process, abc.");
+  }
+
+  @Test
+  public void actionManySpawn() {
+    // Different spawns with the same runner count as one action
+
+    ArrayList<SpawnResult> spawns = new ArrayList<>();
+    spawns.add(rA);
+    spawns.add(rA);
+    spawns.add(rA);
+
+    stats.countActionResult(ActionResult.create(spawns));
+    assertThat(stats.getSummary()).isEqualTo("3 processes, abc.");
+  }
+
+  @Test
+  public void actionManySpawnMixed() {
+    // Different spawns mixed runners
+
+    ArrayList<SpawnResult> spawns = new ArrayList<>();
+    spawns.add(rA);
+    spawns.add(rA);
+    spawns.add(rB);
+
+    stats.countActionResult(ActionResult.create(spawns));
+    assertThat(stats.getSummary()).isEqualTo("3 processes: 2 abc, 1 cde.");
+  }
+
+  @Test
+  public void actionManyActionsMixed() {
+    // Five actions:
+    // abc
+    // abc, abc
+    // abc, abc, cde
+    // abc, abc, cde
+    // abc, abc, cde
+
+    ArrayList<SpawnResult> spawns = new ArrayList<>();
+    spawns.add(rA);
+    stats.countActionResult(ActionResult.create(spawns));
+
+    spawns.add(rA);
+    stats.countActionResult(ActionResult.create(spawns));
+
+    spawns.add(rB);
+    stats.countActionResult(ActionResult.create(spawns));
+    stats.countActionResult(ActionResult.create(spawns));
+    stats.countActionResult(ActionResult.create(spawns));
+
+    assertThat(stats.getSummary()).isEqualTo("12 processes: 9 abc, 3 cde.");
+  }
+}
diff --git a/src/test/shell/bazel/BUILD b/src/test/shell/bazel/BUILD
index d2c7939..9aab738 100644
--- a/src/test/shell/bazel/BUILD
+++ b/src/test/shell/bazel/BUILD
@@ -154,6 +154,12 @@
 )
 
 sh_test(
+    name = "bazel_spawnstats_test",
+    srcs = ["bazel_spawnstats_test.sh"],
+    data = [":test-deps"],
+)
+
+sh_test(
     name = "bazel_coverage_test",
     srcs = ["bazel_coverage_test.sh"],
     data = [":test-deps"],
diff --git a/src/test/shell/bazel/bazel_spawnstats_test.sh b/src/test/shell/bazel/bazel_spawnstats_test.sh
new file mode 100755
index 0000000..958296f
--- /dev/null
+++ b/src/test/shell/bazel/bazel_spawnstats_test.sh
@@ -0,0 +1,85 @@
+#!/bin/bash
+#
+# Copyright 2018 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.
+#
+# Test lightweight spawn stats generation in Bazel
+#
+
+set -eu
+
+# Load the test setup defined in the parent directory
+CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${CURRENT_DIR}/../integration_test_setup.sh" \
+  || { echo "integration_test_setup.sh not found!" >&2; exit 1; }
+
+function set_up() {
+  cat > BUILD <<EOF
+genrule(
+    name = "foo",
+    cmd = "echo hello > \$@",
+    outs = ["foo.txt"],
+)
+EOF
+}
+
+function test_order() {
+  # Ensure the new stats are printed before Build completed
+  bazel build :foo 2>&1 | tee ${TEST_log} | sed -n '/process/,$p' | grep "Build complete" || fail "Expected \"process\" to be followed by \"Build completed\""
+}
+
+# Single execution of Bazel
+function statistics_single() {
+  flags=$1 # flags to pass to Bazel
+  expect=$2 # string to expect
+
+  echo "Starting single run for $flags $expect" &> $TEST_log
+  output=`bazel build :foo $flags 2>&1 | tee ${TEST_log} | grep " process" | tr -d '\r'`
+
+  if ! [[ $output =~ ${expect} ]]; then
+    fail "bazel ${flags}: Want |${expect}|, got |${output}| "
+  fi
+
+  echo "Done $flags $expect" &> $TEST_log
+}
+
+function test_local() {
+  statistics_single "--spawn_strategy=local" ", local"
+}
+
+function test_local_sandbox() {
+  if [[ "$PLATFORM" == "linux" ]]; then
+    statistics_single "--spawn_strategy=linux-sandbox" ", linux-sandbox"
+  fi
+}
+
+# We are correctly resetting the counts
+function test_repeat() {
+  flags="--spawn_strategy=local"
+  statistics_single $flags ", local"
+  bazel clean $flags
+  statistics_single $flags ", local"
+}
+
+# Locally cached results are not yet displayed
+function test_localcache() {
+  flags="--spawn_strategy=local"
+  # We are correctly resetting the counts
+  statistics_single $flags ", local"
+  statistics_single $flags "0 processes."
+}
+
+run_suite "bazel statistics tests"
+
+