Merge "Fork off a custom workspace_status.py with more heuristics" into stable-3.1
diff --git a/.bazelversion b/.bazelversion
index fd2a018..47b322c 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.1.0
+3.4.1
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 47f5a2a..7961b7e 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -856,6 +856,14 @@
 private changes (even without having the `View Private Changes` access
 right assigned).
 
+[[category_toggle_work_in_progress_state]]
+=== Toggle Work In Progress state
+
+This category controls who is able to flip the Work In Progress bit.
+
+Change owner, server administrators and project owners can always flip
+the Work In Progress bit of the change (even without having the
+`Toggle Work In Progress state` access right assigned).
 
 [[category_delete_own_changes]]
 === Delete Own Changes
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 00748a5..1e77c3b 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -146,7 +146,7 @@
 link:https://github.com/google/google-java-format[`google-java-format`]
 tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
-tool (version 3.2.1). Unused dependencies are found and removed using the
+tool (version 3.3.0). Unused dependencies are found and removed using the
 link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`]
 build tool, a sibling of `buildifier`.
 
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index bac5169..3c534eb 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -114,7 +114,7 @@
     "cmd": "clone"
   },
   {
-    "url": "http://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
     "cmd": "clone"
   }
 ]
@@ -151,10 +151,12 @@
 * `-Dcom.google.gerrit.scenarios.hostname=localhost`
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
+* `-Dcom.google.gerrit.scenarios.http_scheme=http`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
-The example values shown above are the currently coded default ones. The framework could support
-differing or more properties over time.
+The example values shown above are the currently coded default ones. For example, the `http` scheme
+above could be replaced with `https`. The framework could support differing or more properties over
+time.
 
 Plugin or otherwise non-core scenarios may do so just as well. The core java package
 `com.google.gerrit.scenarios` from the example above has to be replaced with the one under which
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index aad81bd..4408d93 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2058,6 +2058,14 @@
 No Guice bindings or modules are required. Gerrit will automatically
 discover and bind the implementation.
 
+[[gerrit-replica]]
+== Gerrit Replica
+
+Gerrit can be run as a read-only replica. Some plugins may need to know
+whether Gerrit is run as a primary- or a replica instance. For that purpose
+Gerrit exposes the `@GerritIsReplica` annotation. A boolean annotated with
+this annotation will indicate whether Gerrit is run as a replica.
+
 [[accountcreation]]
 == Account Creation
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 9e1744c..f79918f 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -108,13 +108,14 @@
 +
 ----
   bazel build release Documentation:searchfree
+  ./tools/maven/api.sh war_install
   ./tools/maven/api.sh install
 ----
 
 * Verify the WAR version:
 +
 ----
-  java -jar ~/dl/gerrit-$version.war --version
+  java -jar bazel-bin/release.war --version
 ----
 * Try upgrading a test site and launching the daemon
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 0608659..a54774b 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -568,8 +568,9 @@
 Alternatively, click *Start Review* from the Change screen.
 
 Change owners, project owners, site administrators and members of a group that
-was granted "Toggle Work In Progress state" permission can mark changes as
-`work-in-progress` and `ready`.
+was granted link:access-control.html#category_toggle_work_in_progress_state[
+Toggle Work In Progress state] permission can mark changes as `work-in-progress`
+and `ready`.
 
 [[private-changes]]
 == Private Changes
diff --git a/Documentation/rest-api-plugins.txt b/Documentation/rest-api-plugins.txt
index 77b180e..ce26280 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -48,6 +48,7 @@
       "id": "delete-project",
       "index_url": "plugins/delete-project/",
       "filename": "delete-project.jar",
+      "api_version": "2.9.3-SNAPSHOT",
       "version": "2.9-SNAPSHOT"
     }
   }
@@ -455,12 +456,13 @@
 
 [options="header",cols="1,^2,4"]
 |=======================
-|Field Name ||Description
-|`id`       ||The ID of the plugin.
-|`version`  ||The version of the plugin.
-|`index_url`|optional|URL of the plugin's default page.
-|`filename` |optional|The plugin's filename.
-|`disabled` |not set if `false`|Whether the plugin is disabled.
+|Field Name   ||Description
+|`id`         ||The ID of the plugin.
+|`version`    ||The version of the plugin.
+|`api_version`|optional|The version of the Gerrit Api used by the plugin.
+|`index_url`  |optional|URL of the plugin's default page.
+|`filename`   |optional|The plugin's filename.
+|`disabled`   |not set if `false`|Whether the plugin is disabled.
 |=======================
 
 [[plugin-input]]
diff --git a/WORKSPACE b/WORKSPACE
index 6c2e5cd..3c68232 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -869,54 +869,54 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.27.v20200227"
+JETTY_VERS = "9.4.30.v20200611"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "c6354d1e53c41f839ae56f4d8622c866a1ad8487",
+    sha1 = "ca3dea2cd34ee88cec017001603af0c9e74781d6",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "aead56f2a1ac49d720a192cb7c1568e61e34ddae",
+    sha1 = "1a5261f6ad4081ad9e9bb01416d639931d391273",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "4ef690ce1277e3767d457f87621f2c436a001881",
+    sha1 = "e5ede3724d062717d0c04e4c77f74fe8115c2a6f",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "df66265ec011d8b33a7fa541774257deb957ecb4",
+    sha1 = "653559eaec0f9a335a0d12e90bc764b28f341241",
 )
 
 maven_jar(
     name = "jetty-continuation",
     artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
-    sha1 = "ac504b371dea5316850362a835d14317dfabd5d0",
+    sha1 = "2a9cd8c4cf392a7697a57665e7b0caf5bce4cd48",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "722ba6ef20eb58c55868f1ce85411e6af13be98e",
+    sha1 = "cd6223382e4f82b9ea807d8cdb04a23e5d629f1c",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "e85e7c4f298efb36b80cc53d635f2da776aa54c2",
+    sha1 = "9c360d08e903b2dbd5d1f8e889a32046948628ce",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "44087a126227af5196e3e327a5e11aad1b28852c",
+    sha1 = "39ec6aa4745952077f5407cb1394d8ba2db88b13",
 )
 
 maven_jar(
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
index 3577a6a..665cc4d 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
index 54c54f8..5b892aa 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
index 6210deb..467661b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
     "entries": "PROJECTS_ENTRIES"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 2389124..30f5f23 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -4,7 +4,7 @@
     "cmd": "clone"
   },
   {
-    "url": "http://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index b4ee549..70e79ca 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index 40e5a45..cd90739 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/projects/PROJECT"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 3577a6a..665cc4d 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 7cc8293..5720f53 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
index 9ff15a7..e30a2cf 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
new file mode 100644
index 0000000..e30a2cf
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -0,0 +1,5 @@
+[
+  {
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+  }
+]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
index 2b8809a..86a3c28 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
index fcf4bc9..e4e2643 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
new file mode 100644
index 0000000..f6350be
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -0,0 +1,5 @@
+[
+  {
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+  }
+]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
index 2389124..30f5f23 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
@@ -4,7 +4,7 @@
     "cmd": "clone"
   },
   {
-    "url": "http://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
index a371757..301c65b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "http://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 3dd8493..2e63fd5 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -26,7 +26,7 @@
 
   override def relativeRuntimeWeight = 2
 
-  private val flushCache: ScenarioBuilder = scenario(unique)
+  private val test: ScenarioBuilder = scenario(unique)
       .feed(data)
       .exec(httpRequest)
 
@@ -44,7 +44,7 @@
       nothingFor(stepWaitTime(getCacheEntriesAfterProject) seconds),
       atOnceUsers(single)
     ),
-    flushCache.inject(
+    test.inject(
       nothingFor(stepWaitTime(this) seconds),
       atOnceUsers(single)
     ),
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala
new file mode 100644
index 0000000..7d732ea
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.gerrit.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+import scala.concurrent.duration._
+
+class FlushProjectsCacheThenRebuild extends GerritSimulation {
+  private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+
+  private val test: ScenarioBuilder = scenario(unique)
+      .feed(data)
+      .exec(httpRequest)
+
+  private val checkCacheEntriesAfterFlush = new CheckProjectsCacheFlushEntries
+  private val rebuildCache = new ListProjects
+
+  setUp(
+    test.inject(
+      nothingFor(stepWaitTime(this) seconds),
+      atOnceUsers(single)
+    ),
+    checkCacheEntriesAfterFlush.test.inject(
+      nothingFor(stepWaitTime(checkCacheEntriesAfterFlush) seconds),
+      atOnceUsers(single)
+    ),
+    rebuildCache.test.inject(
+      nothingFor(stepWaitTime(rebuildCache) seconds),
+      atOnceUsers(single)
+    ),
+  ).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index fc68f97..4832392 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -62,6 +62,7 @@
       var in = replaceOverride(url.toString)
       in = replaceProperty("hostname", "localhost", in)
       in = replaceProperty("http_port", 8080, in)
+      in = replaceProperty("http_scheme", "http", in)
       replaceProperty("ssh_port", 29418, in)
     case ("number", number) =>
       val precedes = replaceKeyWith("_number", 0, number.toString)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala
new file mode 100644
index 0000000..bfc97f4
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala
@@ -0,0 +1,33 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.gerrit.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+import io.gatling.http.Predef.http
+
+class ListProjects extends GerritSimulation {
+  private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+
+  val test: ScenarioBuilder = scenario(unique)
+      .feed(data)
+      .exec(http(unique).get("${url}"))
+
+  setUp(
+    test.inject(
+      atOnceUsers(single)
+    )).protocols(httpProtocol)
+}
diff --git a/java/com/google/gerrit/extensions/common/PluginInfo.java b/java/com/google/gerrit/extensions/common/PluginInfo.java
index 0df6235..47f9b6a 100644
--- a/java/com/google/gerrit/extensions/common/PluginInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginInfo.java
@@ -17,13 +17,21 @@
 public class PluginInfo {
   public final String id;
   public final String version;
+  public final String apiVersion;
   public final String indexUrl;
   public final String filename;
   public final Boolean disabled;
 
-  public PluginInfo(String id, String version, String indexUrl, String filename, Boolean disabled) {
+  public PluginInfo(
+      String id,
+      String version,
+      String apiVersion,
+      String indexUrl,
+      String filename,
+      Boolean disabled) {
     this.id = id;
     this.version = version;
+    this.apiVersion = apiVersion;
     this.indexUrl = indexUrl;
     this.filename = filename;
     this.disabled = disabled;
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 73d9ee4..7772660 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CONTENT_LENGTH;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_HOST;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_LATENCY;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_METHOD;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_PROTOCOL;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_REFERER;
@@ -52,6 +53,7 @@
     public String protocol;
     public String status;
     public String contentLength;
+    public String latency;
     public String referer;
     public String userAgent;
 
@@ -65,6 +67,7 @@
       this.protocol = getMdcString(event, P_PROTOCOL);
       this.status = getMdcString(event, P_STATUS);
       this.contentLength = getMdcString(event, P_CONTENT_LENGTH);
+      this.latency = getMdcString(event, P_LATENCY);
       this.referer = getMdcString(event, P_REFERER);
       this.userAgent = getMdcString(event, P_USER_AGENT);
     }
diff --git a/java/com/google/gerrit/server/config/GerritIsReplica.java b/java/com/google/gerrit/server/config/GerritIsReplica.java
new file mode 100644
index 0000000..154fdcd
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritIsReplica.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/* Marker on {@link Boolean} indicating whether Gerrit is run as a read-only replica. */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritIsReplica {}
diff --git a/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java b/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java
new file mode 100644
index 0000000..bd07f7d
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritIsReplicaProvider.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.gerrit.server.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Provides {@link Boolean} annotated with {@link GerritIsReplica}.
+ *
+ * <p>The returned boolean indicates whether Gerrit is run as a read-only replica.
+ */
+@Singleton
+public final class GerritIsReplicaProvider implements Provider<Boolean> {
+  public static final String CONFIG_SECTION = "container";
+  public static final String REPLICA_KEY = "replica";
+  public static final String DEPRECATED_REPLICA_KEY = "slave";
+
+  public final boolean isReplica;
+
+  @Inject
+  public GerritIsReplicaProvider(@GerritServerConfig Config config) {
+    this.isReplica =
+        config.getBoolean(CONFIG_SECTION, REPLICA_KEY, false)
+            || config.getBoolean(CONFIG_SECTION, DEPRECATED_REPLICA_KEY, false);
+  }
+
+  @Override
+  public Boolean get() {
+    return isReplica;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 25ee759..3777a55 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -78,5 +78,8 @@
         .annotatedWith(GerritServerConfig.class)
         .toProvider(GerritServerConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
+    bind(Boolean.class)
+        .annotatedWith(GerritIsReplica.class)
+        .toProvider(GerritIsReplicaProvider.class);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 8434c10..005f4c5 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -42,10 +41,9 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.RejectedExecutionException;
@@ -57,6 +55,7 @@
 
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
@@ -81,22 +80,27 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectHolder implements Comparable<ProjectHolder> {
-    final Project.NameKey name;
-    private final long size;
+  private static class ProjectSlice {
+    private final Project.NameKey name;
+    private final int slice;
+    private final int slices;
 
-    ProjectHolder(Project.NameKey name, long size) {
+    ProjectSlice(Project.NameKey name, int slice, int slices) {
       this.name = name;
-      this.size = size;
+      this.slice = slice;
+      this.slices = slices;
     }
 
-    @Override
-    public int compareTo(ProjectHolder other) {
-      // Sort projects based on size first to maximize utilization of threads early on.
-      return ComparisonChain.start()
-          .compare(other.size, size)
-          .compare(other.name.get(), name.get())
-          .result();
+    public Project.NameKey getName() {
+      return name;
+    }
+
+    public int getSlice() {
+      return slice;
+    }
+
+    public int getSlices() {
+      return slices;
     }
   }
 
@@ -104,19 +108,39 @@
   public Result indexAll(ChangeIndex index) {
     ProgressMonitor pm = new TextProgressMonitor();
     pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    SortedSet<ProjectHolder> projects = new TreeSet<>();
+    List<ProjectSlice> projectSlices = new ArrayList<>();
     int changeCount = 0;
     Stopwatch sw = Stopwatch.createStarted();
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
+        // The simplest approach to distribute indexing would be to let each thread grab a project
+        // and index it fully. But if a site has one big project and 100s of small projects, then
+        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+        // projects have been reindexed, and only the thread that reindexes the big project is
+        // still working. The other threads would idle. Reindexing the big project on a single
+        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+        //
+        // To avoid such situations, we split big repos into smaller parts and let
+        // the thread pool index these smaller parts. This splitting introduces an overhead in the
+        // workload setup and there might be additional slow-downs from multiple threads
+        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+        // in 2020.
         int size = estimateSize(repo);
         changeCount += size;
-        projects.add(new ProjectHolder(name, size));
+        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+        if (slices > 1) {
+          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
+        }
+        for (int slice = 0; slice < slices; slice++) {
+          projectSlices.add(new ProjectSlice(name, slice, slices));
+        }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Error collecting project %s", name);
         projectsFailed++;
-        if (projectsFailed > projects.size() / 2) {
+        if (projectsFailed > projectCache.all().size() / 2) {
           logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
           return new Result(sw, false, 0, 0);
         }
@@ -125,7 +149,15 @@
     }
     pm.endTask();
     setTotalWork(changeCount);
-    return indexAll(index, projects);
+
+    // projectSlices are currently grouped by projects. First all slices for project1, followed
+    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
+    // would typically work concurrently on different slices of the same project. While this is not
+    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
+    // different slices are less likely to be worked on concurrently.
+    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
+    Collections.shuffle(projectSlices);
+    return indexAll(index, projectSlices);
   }
 
   private int estimateSize(Repository repo) throws IOException {
@@ -141,10 +173,10 @@
     return Ints.saturatedCast(size);
   }
 
-  private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
+  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
     Stopwatch sw = Stopwatch.createStarted();
     MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("projects", projects.size());
+    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
     checkState(totalWork >= 0);
     Task doneTask = mpm.beginSubTask(null, totalWork);
     Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
@@ -152,12 +184,21 @@
     List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
 
-    for (ProjectHolder project : projects) {
+    for (ProjectSlice projectSlice : projectSlices) {
+      Project.NameKey name = projectSlice.getName();
+      int slice = projectSlice.getSlice();
+      int slices = projectSlice.getSlices();
       ListenableFuture<?> future =
           executor.submit(
               reindexProject(
-                  indexerFactory.create(executor, index), project.name, doneTask, failedTask));
-      addErrorListener(future, "project " + project.name, projTask, ok);
+                  indexerFactory.create(executor, index),
+                  name,
+                  slice,
+                  slices,
+                  doneTask,
+                  failedTask));
+      String description = "project " + name + " (" + slice + "/" + slices + ")";
+      addErrorListener(future, description, projTask, ok);
       futures.add(future);
     }
 
@@ -192,22 +233,38 @@
 
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return new ProjectIndexer(indexer, project, done, failed);
+    return reindexProject(indexer, project, 0, 1, done, failed);
+  }
+
+  public Callable<Void> reindexProject(
+      ChangeIndexer indexer,
+      Project.NameKey project,
+      int slice,
+      int slices,
+      Task done,
+      Task failed) {
+    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
   }
 
   private class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
     private final Project.NameKey project;
+    private final int slice;
+    private final int slices;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
     private ProjectIndexer(
         ChangeIndexer indexer,
         Project.NameKey project,
+        int slice,
+        int slices,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
       this.project = project;
+      this.slice = slice;
+      this.slices = slices;
       this.done = done;
       this.failed = failed;
     }
@@ -222,7 +279,7 @@
         // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
         // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
         // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, project).forEach(r -> index(r));
+        notesFactory.scan(repo, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
       } catch (RepositoryNotFoundException rnfe) {
         logger.atSevere().log(rnfe.getMessage());
       } finally {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index b7ce2a8..bd673d6 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -206,9 +206,18 @@
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
         throws IOException {
-      ScanResult sr = scanChangeIds(repo);
+      return scan(repo, project, null);
+    }
 
-      return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
+    public Stream<ChangeNotesResult> scan(
+        Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
+        throws IOException {
+      ScanResult sr = scanChangeIds(repo);
+      Stream<Change.Id> idStream = sr.all().stream();
+      if (changeIdPredicate != null) {
+        idStream = idStream.filter(changeIdPredicate);
+      }
+      return idStream.map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
     @Nullable
diff --git a/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
index 090d257..9b74341 100644
--- a/java/com/google/gerrit/server/plugins/CopyConfigModule.java
+++ b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritIsReplica;
+import com.google.gerrit.server.config.GerritIsReplicaProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
@@ -101,6 +103,14 @@
     return secureStore;
   }
 
+  @Inject private GerritIsReplicaProvider isReplicaProvider;
+
+  @Provides
+  @GerritIsReplica
+  boolean getIsReplica() {
+    return isReplicaProvider.get();
+  }
+
   @Inject
   CopyConfigModule() {}
 
diff --git a/java/com/google/gerrit/server/plugins/ListPlugins.java b/java/com/google/gerrit/server/plugins/ListPlugins.java
index 465d041..0408efc 100644
--- a/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -146,12 +146,14 @@
   public static PluginInfo toPluginInfo(Plugin p) {
     String id;
     String version;
+    String apiVersion;
     String indexUrl;
     String filename;
     Boolean disabled;
 
     id = Url.encode(p.getName());
     version = p.getVersion();
+    apiVersion = p.getApiVersion();
     disabled = p.isDisabled() ? true : null;
     if (p.getSrcFile() != null) {
       indexUrl = String.format("plugins/%s/", p.getName());
@@ -161,6 +163,6 @@
       filename = null;
     }
 
-    return new PluginInfo(id, version, indexUrl, filename, disabled);
+    return new PluginInfo(id, version, apiVersion, indexUrl, filename, disabled);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/Plugin.java b/java/com/google/gerrit/server/plugins/Plugin.java
index 5759705..238066b 100644
--- a/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/java/com/google/gerrit/server/plugins/Plugin.java
@@ -116,6 +116,11 @@
     return apiType;
   }
 
+  @Nullable
+  public String getApiVersion() {
+    return null;
+  }
+
   public Plugin.CacheKey getCacheKey() {
     return cacheKey;
   }
diff --git a/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java
index 932a01d..4f00cd0 100644
--- a/java/com/google/gerrit/server/plugins/PluginUtil.java
+++ b/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -53,7 +53,9 @@
   }
 
   static Path asTemp(InputStream in, String prefix, String suffix, Path dir) throws IOException {
-    Files.createDirectories(dir);
+    if (!Files.exists(dir)) {
+      Files.createDirectories(dir);
+    }
     Path tmp = Files.createTempFile(dir, prefix, suffix);
     boolean keep = false;
     try (OutputStream out = Files.newOutputStream(tmp)) {
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index f236202..320b618 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -154,6 +154,13 @@
   }
 
   @Override
+  @Nullable
+  public String getApiVersion() {
+    Attributes main = manifest.getMainAttributes();
+    return main.getValue("Gerrit-ApiVersion");
+  }
+
+  @Override
   protected boolean canReload() {
     Attributes main = manifest.getMainAttributes();
     String v = main.getValue("Gerrit-ReloadMode");
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index e5dad7e..3a952f0 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -49,15 +49,17 @@
           .toJson(output, new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
       stdout.print('\n');
     } else {
-      stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
+      String template = "%-30s %-10s %-16s %-8s %s\n";
+      stdout.format(template, "Name", "Version", "Api-Version", "Status", "File");
       stdout.print(
           "-------------------------------------------------------------------------------\n");
       for (Map.Entry<String, PluginInfo> p : output.entrySet()) {
         PluginInfo info = p.getValue();
         stdout.format(
-            "%-30s %-10s %-8s %s\n",
+            template,
             p.getKey(),
             Strings.nullToEmpty(info.version),
+            Strings.nullToEmpty(info.apiVersion),
             status(info.disabled),
             Strings.nullToEmpty(info.filename));
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index a120eac..67da084 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -37,7 +37,12 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.plugins.MandatoryPluginsCollection;
 import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
 import org.junit.Test;
 
 @NoHttpd
@@ -51,7 +56,14 @@
 
   private static final ImmutableList<String> PLUGINS =
       ImmutableList.of(
-          "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
+          "plugin-a.js",
+          "plugin-b.html",
+          "plugin-c.js",
+          "plugin-d.html",
+          "plugin-normal.jar",
+          "plugin-empty.jar",
+          "plugin-unset.jar",
+          "plugin_e.js");
 
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private MandatoryPluginsCollection mandatoryPluginsCollection;
@@ -67,13 +79,14 @@
     // Install all the plugins
     InstallPluginInput input = new InstallPluginInput();
     for (String plugin : PLUGINS) {
-      input.raw = plugin.endsWith(".js") ? JS_PLUGIN_CONTENT : HTML_PLUGIN_CONTENT;
+      input.raw = pluginContent(plugin);
       api = gApi.plugins().install(plugin, input);
       assertThat(api).isNotNull();
       PluginInfo info = api.get();
       String name = pluginName(plugin);
       assertThat(info.id).isEqualTo(name);
       assertThat(info.version).isEqualTo(pluginVersion(plugin));
+      assertThat(info.apiVersion).isEqualTo(pluginApiVersion(plugin));
       assertThat(info.indexUrl).isEqualTo(String.format("plugins/%s/", name));
       assertThat(info.filename).isEqualTo(plugin);
       assertThat(info.disabled).isNull();
@@ -168,12 +181,52 @@
     return plugin.substring(0, dot);
   }
 
+  private RawInput pluginJarContent(String plugin) throws IOException {
+    ByteArrayOutputStream arrayStream = new ByteArrayOutputStream();
+    Manifest manifest = new Manifest();
+    Attributes attributes = manifest.getMainAttributes();
+    attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
+    if (!plugin.endsWith("-unset.jar")) {
+      attributes.put(Attributes.Name.IMPLEMENTATION_VERSION, pluginVersion(plugin));
+      attributes.put(new Attributes.Name("Gerrit-ApiVersion"), pluginApiVersion(plugin));
+    }
+    try (JarOutputStream jarStream = new JarOutputStream(arrayStream, manifest)) {}
+    return RawInputUtil.create(arrayStream.toByteArray());
+  }
+
+  private RawInput pluginContent(String plugin) throws IOException {
+    if (plugin.endsWith(".js")) {
+      return JS_PLUGIN_CONTENT;
+    }
+    if (plugin.endsWith(".html")) {
+      return HTML_PLUGIN_CONTENT;
+    }
+    assertThat(plugin).endsWith(".jar");
+    return pluginJarContent(plugin);
+  }
+
   private String pluginVersion(String plugin) {
     String name = pluginName(plugin);
+    if (name.endsWith("empty")) {
+      return "";
+    }
+    if (name.endsWith("unset")) {
+      return null;
+    }
     int dash = name.lastIndexOf("-");
     return dash > 0 ? name.substring(dash + 1) : "";
   }
 
+  private String pluginApiVersion(String plugin) {
+    if (plugin.endsWith("normal.jar")) {
+      return "2.16.19-SNAPSHOT";
+    }
+    if (plugin.endsWith("empty.jar")) {
+      return "";
+    }
+    return null;
+  }
+
   private void assertBadRequest(ListRequest req) throws Exception {
     assertThrows(BadRequestException.class, () -> req.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/config/BUILD b/javatests/com/google/gerrit/acceptance/server/config/BUILD
new file mode 100644
index 0000000..17802bd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/config/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_config",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/config/GerritIsReplicaIT.java b/javatests/com/google/gerrit/acceptance/server/config/GerritIsReplicaIT.java
new file mode 100644
index 0000000..d01a81d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/config/GerritIsReplicaIT.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.gerrit.acceptance.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.server.config.GerritIsReplicaProvider;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class GerritIsReplicaIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return new Config();
+  }
+
+  @Inject GerritIsReplicaProvider isReplicaProvider;
+
+  @Test
+  public void isNotReplica() {
+    assertThat(isReplicaProvider.get()).isFalse();
+  }
+
+  @Test
+  @Sandboxed
+  public void isReplica() throws Exception {
+    restartAsSlave();
+    assertThat(isReplicaProvider.get()).isTrue();
+  }
+}
diff --git a/plugins/replication b/plugins/replication
index a2f3dba..a23367b 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit a2f3dba7722e68a7ac1ee917c2bf4d59c3c8d80b
+Subproject commit a23367b33527cba1d7efdcdc77f1e4180eb6d6da
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
index 6ef84bf..ee5dd83 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -26,7 +26,11 @@
 <dom-module id="gr-plugin-list">
   <template>
     <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="gr-table-styles">
+      .placeholder {
+        color: var(--deemphasized-text-color);
+      }
+    </style>
     <gr-list-view
         filter="[[_filter]]"
         items-per-page="[[_pluginsPerPage]]"
@@ -38,6 +42,7 @@
         <tr class="headerRow">
           <th class="name topHeader">Plugin Name</th>
           <th class="version topHeader">Version</th>
+          <th class="apiVersion topHeader">API Version</th>
           <th class="status topHeader">Status</th>
         </tr>
         <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
@@ -54,7 +59,22 @@
                   [[item.id]]
                 </template>
               </td>
-              <td class="version">[[item.version]]</td>
+              <td class="version">
+                <template is="dom-if" if="[[item.version]]">
+                  [[item.version]]
+                </template>
+                <template is="dom-if" if="[[!item.version]]">
+                  <span class="placeholder">--</span>
+                </template>
+              </td>
+              <td class="apiVersion">
+                <template is="dom-if" if="[[item.api_version]]">
+                  [[item.api_version]]
+                </template>
+                <template is="dom-if" if="[[!item.api_version]]">
+                  <span class="placeholder">--</span>
+                </template>
+              </td>
               <td class="status">[[_status(item)]]</td>
             </tr>
           </template>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 96fff60..2485687 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -40,13 +40,18 @@
   const pluginGenerator = () => {
     const plugin = {
       id: `test${++counter}`,
-      version: '3.0-SNAPSHOT',
       disabled: false,
     };
 
     if (counter !== 2) {
       plugin.index_url = `plugins/test${counter}/`;
     }
+    if (counter !== 3) {
+      plugin.version = `version-${counter}`;
+    }
+    if (counter !== 4) {
+      plugin.api_version = `api-version-${counter}`;
+    }
     return plugin;
   };
 
@@ -81,10 +86,11 @@
 
       test('plugin in the list is formatted correctly', done => {
         flush(() => {
-          assert.equal(element._plugins[2].id, 'test3');
-          assert.equal(element._plugins[2].index_url, 'plugins/test3/');
-          assert.equal(element._plugins[2].version, '3.0-SNAPSHOT');
-          assert.equal(element._plugins[2].disabled, false);
+          assert.equal(element._plugins[4].id, 'test5');
+          assert.equal(element._plugins[4].index_url, 'plugins/test5/');
+          assert.equal(element._plugins[4].version, 'version-5');
+          assert.equal(element._plugins[4].api_version, 'api-version-5');
+          assert.equal(element._plugins[4].disabled, false);
           done();
         });
       });
@@ -100,6 +106,25 @@
         });
       });
 
+      test('versions', done => {
+        flush(() => {
+          const versions = Polymer.dom(element.root).querySelectorAll('.version');
+          assert.equal(versions[2].innerText, 'version-2');
+          assert.equal(versions[3].innerText, '--');
+          done();
+        });
+      });
+
+      test('api versions', done => {
+        flush(() => {
+          const apiVersions = Polymer.dom(element.root).querySelectorAll(
+              '.apiVersion');
+          assert.equal(apiVersions[3].innerText, 'api-version-3');
+          assert.equal(apiVersions[4].innerText, '--');
+          done();
+        });
+      });
+
       test('_shownPlugins', () => {
         assert.equal(element._shownPlugins.length, 25);
       });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 0c929b4..02cf861 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -324,6 +324,7 @@
         [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
         [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
         [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+        [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
         [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
         [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       };
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index d49e700..7534501 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -66,7 +66,7 @@
             "export TZ",
             "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(),
             "cd $$TMP",
-            "unzip -q $$ROOT/$<",
+            "unzip -qo $$ROOT/$<",
             "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
             "find . -exec touch '{}' ';'",
             "zip -Xqr $$ROOT/$@ .",