Merge changes I5a30dad3,I1fc60d2a,I3c5e7c15

* changes:
  Add REST endpoint for reindexing an index version
  Add REST API for getting info about indexes
  Optionally allow to reuse existing documents during reindexing
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d9d4643..9ce2eee 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4363,6 +4363,33 @@
 +
 Defaults to `true`.
 
+[[log.daysToKeep]]log.timeToKeep::
++
+Time that logs should be kept until they are being deleted. Values should use common
+suffixes to express their setting:
++
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
++
+The minimum granularity is days. Using a smaller time unit will result in deletion of
+all old logs, as if `0d` would have been configured.
++
+Actively used logs will never be deleted. Thus, this feature only works in combination
+with enabled link:#log.rotate[log.rotate]. Log deletion happens at server startup and
+then daily at 11pm (in the server's local time zone).
++
+Depending on the filesystem the following file times will be used, in order of priority:
++
+* Time of file creation
+* Time when the file was last modified
+* Date added to the filename as part of log file rotation. Time will be set to `00:00:00Z`.
++
+If none of the above is available, the log file won't be deleted.
++
+Defaults to `-1`, i.e. being disabled.
+
 [[metrics]]
 === Section metrics
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f059bf9..091d229 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -63,7 +63,8 @@
     "_number": 4711,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -126,6 +127,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2
     },
     {
       "id": "demo~master~I09c8041b5867d5b33170316e2abc34b79bbb8501",
@@ -143,6 +145,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2,
       "_more_changes": true
     }
   ]
@@ -214,7 +217,8 @@
         "labels": {
           "Verified": {},
           "Code-Review": {}
-        }
+        },
+        "current_revision_number": 2
       }
     ],
     [],
@@ -437,6 +441,7 @@
       "owner": {
         "name": "Shawn Pearce"
       },
+      "current_revision_number": 1,
       "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c",
       "revisions": {
         "184ebe53805e102605d11f6b143486d15c23a09c": {
@@ -598,7 +603,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -680,7 +686,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
     "new_change_info": {
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
@@ -709,7 +716,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
   }
 ----
@@ -978,7 +986,8 @@
         "message": "Patch Set 1:\n\nThis is the second message.\n\nWith a line break.",
         "_revision_number": 1
       }
-    ]
+    ],
+    "current_revision_number": 2
   }
 ----
 
@@ -1040,6 +1049,7 @@
     "owner": {
       "_account_id": 1000000
     },
+    "current_revision_number": 1,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
   }
 ----
@@ -1268,7 +1278,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1339,7 +1350,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1411,6 +1423,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -1561,6 +1574,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
         "revisions": {
           "c3b2ba222d42a56e05c90f88d4509a124620517d": {
@@ -1638,6 +1652,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
         "revisions": {
           "77eb17a9501a5c21963bc6af56085e60f281acbb": {
@@ -1762,7 +1777,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1841,7 +1857,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1935,7 +1952,8 @@
         "_number": 3965,
         "owner": {
           "name": "John Doe"
-        }
+        },
+        "current_revision_number": 2
       },
       {
         "id": "anyProject~master~1eee2c9d8f352483781e772f35dc586a69ff5646",
@@ -1953,7 +1971,8 @@
         "_number": 3966,
         "owner": {
           "name": "Jane Doe"
-        }
+        },
+        "current_revision_number": 2
       }
     ]
 ----
@@ -2038,7 +2057,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -2191,6 +2211,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
       "revisions": {
         "9adb9f4c7b40eeee0646e235de818d09164d7379": {
@@ -2288,6 +2309,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
       "revisions": {
         "1bd7c12a38854a2c6de426feec28800623f492c4": {
@@ -2397,6 +2419,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 1,
     "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
   }
 ----
@@ -2661,6 +2684,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 1 not found"
@@ -2713,6 +2737,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 2 not found"
@@ -4427,6 +4452,7 @@
         }
       ]
     },
+    "current_revision_number": 2,
     "current_revision": "674ac754f91e64a0efb8087e59a176484bd534d1",
     "revisions": {
       "674ac754f91e64a0efb8087e59a176484bd534d1": {
@@ -4818,6 +4844,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -4918,7 +4945,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -6491,7 +6519,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -7260,6 +7289,8 @@
 Messages associated with the change as a list of
 link:#change-message-info[ChangeMessageInfo] entities. +
 Only set if link:#messages[messages] are requested.
+|`current_revision_number`||The number of the current patch set of this
+change. +
 |`current_revision`   |optional|
 The commit ID of the current patch set of this change. +
 Only set if link:#current-revision[the current revision] is requested
@@ -8747,9 +8778,8 @@
 action. Not set if false.
 |`error`                  |optional|
 Error message for non-200 responses.
-|`change_info`            |optional|
-Post-update change information. Only set if `response_format_options` were
-set.
+|`change_info`            ||
+Post-update change information.
 |============================
 
 [[reviewer-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 148a4b5..d8ff80a 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -931,7 +931,7 @@
       "state": "SLEEPING",
       "start_time": "2014-06-11 12:58:51.508000000",
       "delay": 3287966,
-      "command": "Log File Compressor"
+      "command": "Log File Manager"
     }
   ]
 ----
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index a2e2e8f..33a2f34 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -115,6 +115,7 @@
   public Collection<ReviewerUpdateInfo> reviewerUpdates;
   public Collection<ChangeMessageInfo> messages;
 
+  public Integer currentRevisionNumber;
   public String currentRevision;
   public Map<String, RevisionInfo> revisions;
   public Boolean _moreChanges;
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index ba88617..fa67034 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
@@ -302,7 +302,7 @@
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
     modules.add(new EventBrokerModule());
     modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index d213a60..198eeaa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -52,7 +52,7 @@
 import com.google.gerrit.pgm.http.jetty.JettyModule;
 import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter.ProjectQoSFilterModule;
 import com.google.gerrit.pgm.util.ErrorLogFile;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
@@ -450,7 +450,7 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
deleted file mode 100644
index 5e49312..0000000
--- a/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) 2009 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.pgm.util;
-
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.temporal.ChronoUnit;
-import java.util.concurrent.Future;
-import java.util.zip.GZIPOutputStream;
-import org.eclipse.jgit.lib.Config;
-
-/** Compresses the old error logs. */
-public class LogFileCompressor implements Runnable {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static class LogFileCompressorModule extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(Lifecycle.class);
-    }
-  }
-
-  static class Lifecycle implements LifecycleListener {
-    private final WorkQueue queue;
-    private final LogFileCompressor compressor;
-    private final boolean enabled;
-
-    @Inject
-    Lifecycle(WorkQueue queue, LogFileCompressor compressor, @GerritServerConfig Config config) {
-      this.queue = queue;
-      this.compressor = compressor;
-      this.enabled = config.getBoolean("log", "compress", true);
-    }
-
-    @Override
-    public void start() {
-      if (!enabled) {
-        return;
-      }
-      // compress log once and then schedule compression every day at 11:00pm
-      queue.getDefaultQueue().execute(compressor);
-      ZoneId zone = ZoneId.systemDefault();
-      LocalDateTime now = LocalDateTime.now(zone);
-      long milliSecondsUntil11pm =
-          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError =
-          queue
-              .getDefaultQueue()
-              .scheduleAtFixedRate(
-                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  private final Path logs_dir;
-
-  @Inject
-  LogFileCompressor(SitePaths site) {
-    logs_dir = resolve(site.logs_dir);
-  }
-
-  private static Path resolve(Path p) {
-    try {
-      return p.toRealPath().normalize();
-    } catch (IOException e) {
-      return p.toAbsolutePath().normalize();
-    }
-  }
-
-  @Override
-  public void run() {
-    try {
-      if (!Files.isDirectory(logs_dir)) {
-        return;
-      }
-      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
-        for (Path entry : list) {
-          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
-            compress(entry);
-          }
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
-      }
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Failed to compress log files: %s", e.getMessage());
-    }
-  }
-
-  private boolean isLive(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith("_log")
-        || name.endsWith(".log")
-        || name.endsWith(".run")
-        || name.endsWith(".pid")
-        || name.endsWith(".json");
-  }
-
-  private boolean isCompressed(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith(".gz") //
-        || name.endsWith(".zip") //
-        || name.endsWith(".bz2");
-  }
-
-  private boolean isLogFile(Path entry) {
-    return Files.isRegularFile(entry);
-  }
-
-  private void compress(Path src) {
-    Path dst = src.resolveSibling(src.getFileName() + ".gz");
-    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
-    try {
-      try (InputStream in = Files.newInputStream(src);
-          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
-        ByteStreams.copy(in, out);
-      }
-      tmp.toFile().setReadOnly();
-      try {
-        Files.move(tmp, dst);
-      } catch (IOException e) {
-        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
-      }
-      Files.delete(src);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot compress %s", src);
-      try {
-        Files.deleteIfExists(tmp);
-      } catch (IOException e2) {
-        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
-      }
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "Log File Compressor";
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/LogFileManager.java b/java/com/google/gerrit/pgm/util/LogFileManager.java
new file mode 100644
index 0000000..902f7d64
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/LogFileManager.java
@@ -0,0 +1,249 @@
+// Copyright (C) 2009 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.pgm.util;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPOutputStream;
+import org.eclipse.jgit.lib.Config;
+
+/** Compresses and eventually deletes the old logs. */
+public class LogFileManager implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Pattern LOG_FILENAME_PATTERN =
+      Pattern.compile("^.+(?<date>\\d{4}-\\d{2}-\\d{2})(.gz)?");
+  protected final boolean compressionEnabled;
+  private final Duration timeToKeep;
+
+  public static class LogFileManagerModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final LogFileManager manager;
+
+    @Inject
+    Lifecycle(WorkQueue queue, LogFileManager manager) {
+      this.queue = queue;
+      this.manager = manager;
+    }
+
+    @Override
+    public void start() {
+      if (!manager.compressionEnabled && manager.timeToKeep.isNegative()) {
+        return;
+      }
+      // compress log once and then schedule compression every day at 11:00pm
+      queue.getDefaultQueue().execute(manager);
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDateTime now = LocalDateTime.now(zone);
+      long milliSecondsUntil11pm =
+          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          queue
+              .getDefaultQueue()
+              .scheduleAtFixedRate(
+                  manager, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  private final Path logs_dir;
+
+  @Inject
+  LogFileManager(SitePaths site, @GerritServerConfig Config config) {
+    this.logs_dir = resolve(site.logs_dir);
+    this.compressionEnabled = config.getBoolean("log", "compress", true);
+    this.timeToKeep = getTimeToKeep(config);
+  }
+
+  private Duration getTimeToKeep(Config config) {
+    try {
+      return Duration.ofDays(
+          ConfigUtil.getTimeUnit(config, "log", null, "timeToKeep", -1, TimeUnit.DAYS));
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Illegal duration value for log deletion. Disabling log deletion.");
+      return Duration.ofDays(-1L);
+    }
+  }
+
+  private static Path resolve(Path p) {
+    try {
+      return p.toRealPath().normalize();
+    } catch (IOException e) {
+      return p.toAbsolutePath().normalize();
+    }
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("Starting log file maintenance.");
+    try {
+      if (!Files.isDirectory(logs_dir)) {
+        return;
+      }
+      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
+        for (Path entry : list) {
+          if (isLive(entry) || !isLogFile(entry)) {
+            continue;
+          }
+          if (!timeToKeep.isNegative() && isExpired(entry)) {
+            if (delete(entry)) {
+              continue;
+            }
+          }
+          if (compressionEnabled && !isCompressed(entry)) {
+            compress(entry);
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Failed to process log files: %s", e.getMessage());
+    }
+    logger.atInfo().log("Log file maintenance has finished.");
+  }
+
+  private boolean isLive(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith("_log")
+        || name.endsWith(".log")
+        || name.endsWith(".run")
+        || name.endsWith(".pid")
+        || name.endsWith(".json");
+  }
+
+  private boolean isCompressed(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith(".gz") //
+        || name.endsWith(".zip") //
+        || name.endsWith(".bz2");
+  }
+
+  private boolean isLogFile(Path entry) {
+    return Files.isRegularFile(entry);
+  }
+
+  @VisibleForTesting
+  boolean isExpired(Path entry) {
+    try {
+      FileTime creationTime = Files.readAttributes(entry, BasicFileAttributes.class).creationTime();
+
+      if (creationTime.toInstant().equals(Instant.EPOCH)) {
+        Optional<Instant> fileDate = getDateFromFilename(entry);
+        if (fileDate.isPresent()) {
+          return fileDate.get().isBefore(Instant.now().minus(timeToKeep));
+        }
+        return false;
+      }
+
+      return creationTime.toInstant().isBefore(Instant.now().minus(timeToKeep));
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Failed to get creation time of log file %s", entry);
+    }
+    return false;
+  }
+
+  @VisibleForTesting
+  Optional<Instant> getDateFromFilename(Path entry) {
+    Matcher filenameMatcher = LOG_FILENAME_PATTERN.matcher(entry.getFileName().toString());
+    if (filenameMatcher.matches()) {
+      String rotationDate = filenameMatcher.group("date");
+      if (rotationDate != null && !rotationDate.isBlank()) {
+        return Optional.of(Instant.parse(rotationDate + "T00:00:00.00Z"));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private boolean delete(Path entry) {
+    try {
+      Files.deleteIfExists(entry);
+      logger.atInfo().log("Log file %s has been deleted.", entry);
+      return true;
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to delete log file %s", entry);
+    }
+    return false;
+  }
+
+  private void compress(Path src) {
+    Path dst = src.resolveSibling(src.getFileName() + ".gz");
+    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
+    try {
+      try (InputStream in = Files.newInputStream(src);
+          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
+        ByteStreams.copy(in, out);
+      }
+      tmp.toFile().setReadOnly();
+      try {
+        Files.move(tmp, dst);
+      } catch (IOException e) {
+        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
+      }
+      Files.delete(src);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Cannot compress %s", src);
+      try {
+        Files.deleteIfExists(tmp);
+      } catch (IOException e2) {
+        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Log File Manager";
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 29f5a7c..d08aeb7 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -365,7 +365,8 @@
               + e.getDuplicateKey().get()
               + "\" to account "
               + newId
-              + "; external ID already in use.");
+              + "; external ID already in use.",
+          e);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
index 9253133..c861bc0 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGeneratorImpl;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 
@@ -25,5 +27,6 @@
     install(new ExternalIdNoteDbReadStorageModule());
 
     bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+    bind(MessageIdGenerator.class).to(MessageIdGeneratorImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
index 0ed1f11..b5ac87f5 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -63,8 +63,8 @@
     return format.suffixes();
   }
 
-  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
-    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  public ArchiveOutputStream<?> createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream<?>) this.format.createArchiveOutputStream(o);
   }
 
   public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 3b07829..0483031 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -639,6 +639,7 @@
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().shortName();
+    out.currentRevisionNumber = in.currentPatchSetId().get();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
       out.removedFromAttentionSet =
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 169d9ec..6e957e6 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -23,6 +22,10 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.cache.proto.Cache.CachedPreferencesProto;
+import com.google.gerrit.server.config.PreferencesParserUtil.DiffPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.EditPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.GeneralPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.PreferencesParser;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -67,38 +70,17 @@
 
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseGeneralPreferences,
-        p ->
-            UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
-                p.getGeneralPreferencesInfo()),
-        GeneralPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, GeneralPreferencesParser.Instance);
   }
 
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseDiffPreferences,
-        p ->
-            UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
-                p.getDiffPreferencesInfo()),
-        DiffPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, DiffPreferencesParser.Instance);
   }
 
   public static EditPreferencesInfo edit(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseEditPreferences,
-        p ->
-            UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
-                p.getEditPreferencesInfo()),
-        EditPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, EditPreferencesParser.Instance);
   }
 
   public Config asConfig() {
@@ -135,34 +117,26 @@
     return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
   }
 
-  @FunctionalInterface
-  private interface ComputePreferencesFn<PreferencesT> {
-    PreferencesT apply(Config cfg, @Nullable Config defaultCfg, @Nullable PreferencesT input)
-        throws ConfigInvalidException;
-  }
-
   private static <PreferencesT> PreferencesT getPreferences(
       Optional<CachedPreferences> defaultPreferences,
       CachedPreferences userPreferences,
-      ComputePreferencesFn<PreferencesT> computePreferencesFn,
-      Function<UserPreferences, PreferencesT> fromUserPreferencesFn,
-      PreferencesT javaDefaults) {
+      PreferencesParser<PreferencesT> preferencesParser) {
     try {
       CachedPreferencesProto userPreferencesProto = userPreferences.config();
       switch (userPreferencesProto.getPreferencesCase()) {
         case USER_PREFERENCES:
           PreferencesT pref =
-              fromUserPreferencesFn.apply(userPreferencesProto.getUserPreferences());
-          return computePreferencesFn.apply(new Config(), configOrNull(defaultPreferences), pref);
+              preferencesParser.fromUserPreferences(userPreferencesProto.getUserPreferences());
+          return preferencesParser.parse(pref, configOrNull(defaultPreferences));
         case LEGACY_GIT_CONFIG:
-          return computePreferencesFn.apply(
+          return preferencesParser.parse(
               userPreferences.asConfig(), configOrNull(defaultPreferences), null);
         case PREFERENCES_NOT_SET:
           throw new ConfigInvalidException("Invalid config " + userPreferences);
       }
     } catch (ConfigInvalidException e) {
-      return javaDefaults;
+      return preferencesParser.getJavaDefaults();
     }
-    return javaDefaults;
+    return preferencesParser.getJavaDefaults();
   }
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 44bfa48..e76207c 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -397,6 +397,50 @@
   }
 
   /**
+   * Merges config by inspecting Java class attributes, similar to {@link #loadSection}.
+   *
+   * <p>Config values are stored optimized: no default values are stored. The loading is performed
+   * eagerly: all values are set, except default boolean values.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
+   *
+   * @param cfg config from which the values are loaded
+   * @param s instance of class in which the values are set
+   * @param defaults instance of class with default values
+   * @return loaded instance
+   */
+  @CanIgnoreReturnValue
+  public static <T> T mergeWithDefaults(T cfg, T s, T defaults) throws ConfigInvalidException {
+    try {
+      for (Field f : s.getClass().getDeclaredFields()) {
+        if (skipField(f)) {
+          continue;
+        }
+        Class<?> t = f.getType();
+        String n = f.getName();
+        f.setAccessible(true);
+
+        Object val = f.get(cfg);
+        if (val == null) {
+          val = f.get(defaults);
+          if (!isString(t) && !isCollectionOrMap(t)) {
+            requireNonNull(val, "Default cannot be null for: " + n);
+          }
+        }
+        if (!isBoolean(t) || (boolean) val) {
+          // To reproduce the same behavior as in the loadSection method above, values are
+          // explicitly set for all types, except the boolean type. For the boolean type, the value
+          // is set only if it is 'true' (so, the false value is omitted in the result object).
+          f.set(s, val);
+        }
+      }
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
+      throw new ConfigInvalidException("cannot load values", e);
+    }
+    return s;
+  }
+
+  /**
    * Update user config by applying the specified delta
    *
    * <p>As opposed to {@link com.google.gerrit.server.config.ConfigUtil#storeSection}, this method
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index fbdb324..93df926 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.mergeWithDefaults;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.git.UserConfigSections;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -66,13 +68,31 @@
       r.my = input.my;
     } else {
       r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
+      r.my = parseMyMenus(my(cfg), defaultCfg);
     }
     return r;
   }
 
   /**
    * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs and {@code cfg} for the user's config.
+   */
+  public static GeneralPreferencesInfo parseGeneralPreferences(
+      GeneralPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        mergeWithDefaults(
+            cfg,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, null)
+                : GeneralPreferencesInfo.defaults());
+    r.changeTable = cfg.changeTable != null ? cfg.changeTable : Lists.newArrayList();
+    r.my = parseMyMenus(cfg.my, defaultCfg);
+    return r;
+  }
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
    * the server's default configs. These configs are then overlaid to inherit values (default ->
    * input (if provided).
    */
@@ -110,6 +130,20 @@
 
   /**
    * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static DiffPreferencesInfo parseDiffPreferences(
+      DiffPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, null)
+            : DiffPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -147,6 +181,20 @@
 
   /**
    * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static EditPreferencesInfo parseEditPreferences(
+      EditPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, null)
+            : EditPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -171,11 +219,14 @@
     return changeTable;
   }
 
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
+  private static List<MenuItem> parseMyMenus(
+      @Nullable List<MenuItem> my, @Nullable Config defaultCfg) {
+    if (defaultCfg != null && (my == null || my.isEmpty())) {
       my = my(defaultCfg);
     }
+    if (my == null) {
+      my = new ArrayList<>();
+    }
     if (my.isEmpty()) {
       my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
       my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
@@ -264,4 +315,110 @@
     String val = cfg.getString(UserConfigSections.MY, subsection, key);
     return !Strings.isNullOrEmpty(val) ? val : defaultValue;
   }
+
+  /** Provides methods for parsing user configs */
+  public interface PreferencesParser<T> {
+    T parse(Config cfg, @Nullable Config defaultConfig, @Nullable T input)
+        throws ConfigInvalidException;
+
+    T parse(T cfg, @Nullable Config defaultConfig) throws ConfigInvalidException;
+
+    T fromUserPreferences(UserPreferences userPreferences);
+
+    T getJavaDefaults();
+  }
+
+  /** Provides methods for parsing GeneralPreferencesInfo configs */
+  public static class GeneralPreferencesParser
+      implements PreferencesParser<GeneralPreferencesInfo> {
+    public static GeneralPreferencesParser Instance = new GeneralPreferencesParser();
+
+    private GeneralPreferencesParser() {}
+
+    @Override
+    public GeneralPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public GeneralPreferencesInfo parse(GeneralPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public GeneralPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
+          p.getGeneralPreferencesInfo());
+    }
+
+    @Override
+    public GeneralPreferencesInfo getJavaDefaults() {
+      return GeneralPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing EditPreferencesInfo configs */
+  public static class EditPreferencesParser implements PreferencesParser<EditPreferencesInfo> {
+    public static EditPreferencesParser Instance = new EditPreferencesParser();
+
+    private EditPreferencesParser() {}
+
+    @Override
+    public EditPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public EditPreferencesInfo parse(EditPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public EditPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
+          p.getEditPreferencesInfo());
+    }
+
+    @Override
+    public EditPreferencesInfo getJavaDefaults() {
+      return EditPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing DiffPreferencesInfo configs */
+  public static class DiffPreferencesParser implements PreferencesParser<DiffPreferencesInfo> {
+    public static DiffPreferencesParser Instance = new DiffPreferencesParser();
+
+    private DiffPreferencesParser() {}
+
+    @Override
+    public DiffPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public DiffPreferencesInfo parse(DiffPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public DiffPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
+          p.getDiffPreferencesInfo());
+    }
+
+    @Override
+    public DiffPreferencesInfo getJavaDefaults() {
+      return DiffPreferencesInfo.defaults();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 4a052d7..7eae7d0 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -180,20 +181,24 @@
         MenuItem javaItem) {
       UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
           UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder();
-      builder = setIfNotNull(builder, builder::setName, javaItem.name);
-      builder = setIfNotNull(builder, builder::setUrl, javaItem.url);
-      builder = setIfNotNull(builder, builder::setTarget, javaItem.target);
-      builder = setIfNotNull(builder, builder::setId, javaItem.id);
+      builder = setIfNotNull(builder, builder::setName, trimSafe(javaItem.name));
+      builder = setIfNotNull(builder, builder::setUrl, trimSafe(javaItem.url));
+      builder = setIfNotNull(builder, builder::setTarget, trimSafe(javaItem.target));
+      builder = setIfNotNull(builder, builder::setId, trimSafe(javaItem.id));
       return builder.build();
     }
 
+    private static @Nullable String trimSafe(@Nullable String s) {
+      return s == null ? s : s.trim();
+    }
+
     private static MenuItem menuItemFromProto(
         UserPreferences.GeneralPreferencesInfo.MenuItem proto) {
       return new MenuItem(
-          proto.hasName() ? proto.getName() : null,
-          proto.hasUrl() ? proto.getUrl() : null,
-          proto.hasTarget() ? proto.getTarget() : null,
-          proto.hasId() ? proto.getId() : null);
+          proto.hasName() ? proto.getName().trim() : null,
+          proto.hasUrl() ? proto.getUrl().trim() : null,
+          proto.hasTarget() ? proto.getTarget().trim() : null,
+          proto.hasId() ? proto.getId().trim() : null);
     }
 
     private GeneralPreferencesInfoConverter() {}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 7caa988..4399ad4 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -184,6 +184,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -367,9 +368,9 @@
   private final AccountResolver accountResolver;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final CancellationMetrics cancellationMetrics;
   private final ChangeEditUtil editUtil;
-  private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeNotes.Factory notesFactory;
@@ -456,11 +457,11 @@
       AccountResolver accountResolver,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       CancellationMetrics cancellationMetrics,
       ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
-      ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
@@ -514,6 +515,7 @@
     this.accountResolver = accountResolver;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.cancellationMetrics = cancellationMetrics;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeUtil = changeUtil;
@@ -527,7 +529,6 @@
     this.deadlineCheckerFactory = deadlineCheckerFactory;
     this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
     this.editUtil = editUtil;
-    this.changeDataFactory = changeDataFactory;
     this.hashtagsFactory = hashtagsFactory;
     this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
@@ -887,7 +888,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(changeDataFactory, false, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
index b32c43a..29b914f 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -1,58 +1,26 @@
-// 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.mail.send;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.MailMessage;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.update.RepoView;
-import com.google.inject.Inject;
-import java.io.IOException;
 import java.time.Instant;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
 
-/** A generator class that creates a {@link MessageId} */
-public class MessageIdGenerator {
-  private final GitRepositoryManager repositoryManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
-    this.repositoryManager = repositoryManager;
-    this.allUsersName = allUsersName;
-  }
-
+public interface MessageIdGenerator {
   /**
    * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
    * emails are sent via {@link OutgoingEmail#send()}.
    */
   @AutoValue
-  public abstract static class MessageId {
+  abstract class MessageId {
     public abstract String id();
+
+    public static MessageId create(String id) {
+      return new AutoValue_MessageIdGenerator_MessageId(id);
+    }
   }
 
   /**
@@ -60,43 +28,19 @@
    *
    * @return MessageId that depends on the patchset.
    */
-  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
-    return fromChangeUpdateAndReason(repoView, patchsetId, null);
-  }
+  MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId);
 
-  public MessageId fromChangeUpdateAndReason(
-      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
-    String suffix = (reason != null) ? ("-" + reason) : "";
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Optional<ObjectId> metaSha1;
-    try {
-      metaSha1 = repoView.getRef(metaRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-    return metaSha1
-        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName() + suffix))
-        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
-  }
+  MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason);
 
-  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Ref ref = getRef(metaRef, project);
-    checkState(ref != null, metaRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId);
 
   /**
    * Create a {@link MessageId} as a result of an account update
    *
    * @return {@link MessageId} that depends on the account id.
    */
-  public MessageId fromAccountUpdate(Account.Id accountId) {
-    String userRef = RefNames.refsUsers(accountId);
-    Ref ref = getRef(userRef, allUsersName);
-    checkState(ref != null, userRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromAccountUpdate(Account.Id accountId);
 
   /**
    * Create a {@link MessageId} from a mail message.
@@ -104,9 +48,7 @@
    * @param mailMessage The message that was sent but was rejected.
    * @return MessageId that depends on the MailMessage that was rejected.
    */
-  public MessageId fromMailMessage(MailMessage mailMessage) {
-    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
-  }
+  MessageId fromMailMessage(MailMessage mailMessage);
 
   /**
    * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
@@ -114,17 +56,5 @@
    * @param reason for performing this account update
    * @return MessageId that depends on the reason, accountId, and timestamp.
    */
-  public MessageId fromReasonAccountIdAndTimestamp(
-      String reason, Account.Id accountId, Instant timestamp) {
-    return new AutoValue_MessageIdGenerator_MessageId(
-        reason + "-" + accountId.toString() + "-" + timestamp.toString());
-  }
-
-  private Ref getRef(String userRef, Project.NameKey project) {
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      return repository.getRefDatabase().findRef(userRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-  }
+  MessageId fromReasonAccountIdAndTimestamp(String reason, Account.Id accountId, Instant timestamp);
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
new file mode 100644
index 0000000..292d042
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2024 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.mail.send;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** A generator class that creates a {@link MessageId} */
+public class MessageIdGeneratorImpl implements MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGeneratorImpl(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    return fromChangeUpdateAndReason(repoView, patchsetId, null);
+  }
+
+  @Override
+  public MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
+    String suffix = (reason != null) ? ("-" + reason) : "";
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> MessageId.create(optional.getName() + suffix))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return MessageId.create(mailMessage.id() + "-REJECTION");
+  }
+
+  @Override
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return MessageId.create(reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index 6fe464d..79038af 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.restapi.change.CommentJson;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -64,6 +65,7 @@
 @Singleton
 public class DeleteDraftCommentsUtil {
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
@@ -77,6 +79,7 @@
   @Inject
   public DeleteDraftCommentsUtil(
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
@@ -86,6 +89,7 @@
       DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil) {
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
@@ -122,7 +126,7 @@
       // were,
       // all updates from this operation only happen in All-Users and thus are fully atomic, so
       // allowing partial failure would have little value.
-      BatchUpdate.execute(changeDataFactory, updates.values(), ImmutableList.of(), false);
+      batchUpdates.execute(updates.values(), ImmutableList.of(), false);
     }
     return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 709d6a7..3c30b84 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -94,6 +94,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -143,7 +145,7 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  private final BatchUpdate.Factory updateFactory;
+  private final RetryHelper retryHelper;
   private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final AccountCache accountCache;
@@ -167,7 +169,7 @@
 
   @Inject
   PostReview(
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
       AccountCache accountCache,
@@ -186,7 +188,7 @@
       ReviewerAdded reviewerAdded,
       ChangeJson.Factory changeJsonFactory,
       CommentsValidator commentsValidator) {
-    this.updateFactory = updateFactory;
+    this.retryHelper = retryHelper;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.accountCache = accountCache;
@@ -290,100 +292,56 @@
     }
     output.labels = input.labels;
 
-    BatchUpdate.Result batchUpdateResult;
-
-    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      try (BatchUpdate bu =
-          updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
-        bu.setNotify(notify);
-
-        Account account = revision.getUser().asIdentifiedUser().getAccount();
-        boolean ccOrReviewer = false;
-        if (input.labels != null && !input.labels.isEmpty()) {
-          ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
-          if (ccOrReviewer) {
-            logger.atFine().log(
-                "calling user is cc/reviewer on the change due to voting on a label");
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // Check if user was already CCed or reviewing prior to this review.
-          ReviewerSet currentReviewers =
-              approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-          ccOrReviewer = currentReviewers.all().contains(account.id());
-          if (ccOrReviewer) {
-            logger.atFine().log("calling user is already cc/reviewer on the change");
-          }
-        }
-
-        // Apply reviewer changes first. Revision emails should be sent to the
-        // updated set of reviewers. Also keep track of whether the user added
-        // themselves as a reviewer or to the CC list.
-        logger.atFine().log("adding reviewer additions");
-        for (ReviewerModification reviewerResult : reviewerResults) {
-          reviewerResult.op.suppressEmail(); // Send a single batch email below.
-          reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
-          bu.addOp(revision.getChange().getId(), reviewerResult.op);
-          if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
-            logger.atFine().log("calling user is explicitly added as reviewer or CC");
-            ccOrReviewer = true;
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // User posting this review isn't currently in the reviewer or CC list,
-          // isn't being explicitly added, and isn't voting on any label.
-          // Automatically CC them on this change so they receive replies.
-          logger.atFine().log("CCing calling user");
-          ReviewerModification selfAddition =
-              reviewerModifier.ccCurrentUser(revision.getUser(), revision);
-          selfAddition.op.suppressEmail();
-          selfAddition.op.suppressEvent();
-          bu.addOp(revision.getChange().getId(), selfAddition.op);
-        }
-
-        // Add WorkInProgressOp if requested.
-        if ((input.ready || input.workInProgress)
-            && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
-          if (input.ready && input.workInProgress) {
-            output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
-            return Response.withStatusCode(SC_BAD_REQUEST, output);
-          }
-
-          revision
-              .getChangeResource()
-              .permissions()
-              .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-
-          if (input.ready) {
-            output.ready = true;
-          }
-
-          logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
-          WorkInProgressOp wipOp =
-              workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
-          wipOp.suppressEmail();
-          bu.addOp(revision.getChange().getId(), wipOp);
-        }
-
-        // Add the review ops.
-        logger.atFine().log("posting review");
-        PostReviewOp postReviewOp =
-            postReviewOpFactory.create(
-                projectState, revision.getPatchSet().id(), input, revision.getAccountId());
-        bu.addOp(revision.getChange().getId(), postReviewOp);
-
-        // Adjust the attention set based on the input
-        replyAttentionSetUpdates.updateAttentionSetOnPostReview(
-            bu, postReviewOp, revision.getNotes(), input, revision.getUser());
-
-        batchUpdateResult = bu.execute();
+    Account account = revision.getUser().asIdentifiedUser().getAccount();
+    boolean ccOrReviewer = false;
+    if (input.labels != null && !input.labels.isEmpty()) {
+      ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
       }
     }
 
+    if (!ccOrReviewer) {
+      // Check if user was already CCed or reviewing prior to this review.
+      ReviewerSet currentReviewers =
+          approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
+      ccOrReviewer = currentReviewers.all().contains(account.id());
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is already cc/reviewer on the change");
+      }
+    }
+
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.op.suppressEmail(); // Send a single batch email below.
+      reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
+      if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
+        logger.atFine().log("calling user is explicitly added as reviewer or CC");
+        ccOrReviewer = true;
+      }
+    }
+
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
+    if ((input.ready || input.workInProgress)
+        && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+      if (input.ready && input.workInProgress) {
+        output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+        return Response.withStatusCode(SC_BAD_REQUEST, output);
+      }
+
+      revision
+          .getChangeResource()
+          .permissions()
+          .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
+      if (input.ready) {
+        output.ready = true;
+      }
+    }
+
+    BatchUpdates.Result batchUpdateResult =
+        runBatchUpdate(projectState, revision, input, ts, notify, reviewerResults, ccOrReviewer);
     ChangeData cd =
         batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
     for (ReviewerModification reviewerResult : reviewerResults) {
@@ -397,11 +355,83 @@
 
     if (input.responseFormatOptions != null) {
       output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+    } else {
+      output.changeInfo = changeJsonFactory.noOptions().format(cd);
     }
 
     return Response.ok(output);
   }
 
+  private BatchUpdates.Result runBatchUpdate(
+      ProjectState projectState,
+      RevisionResource revision,
+      ReviewInput input,
+      Instant ts,
+      NotifyResolver.Result notify,
+      List<ReviewerModification> reviewerResults,
+      boolean ccOrReviewer)
+      throws UpdateException, RestApiException {
+    return retryHelper
+        .changeUpdate(
+            "batchUpdate",
+            updateFactory -> {
+              try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+                try (BatchUpdate bu =
+                    updateFactory.create(
+                        revision.getChange().getProject(), revision.getUser(), ts)) {
+                  bu.setNotify(notify);
+
+                  // Apply reviewer changes first. Revision emails should be sent to the
+                  // updated set of reviewers. Also keep track of whether the user added
+                  // themselves as a reviewer or to the CC list.
+                  logger.atFine().log("adding reviewer additions");
+                  reviewerResults.forEach(
+                      reviewerResult -> bu.addOp(revision.getChange().getId(), reviewerResult.op));
+
+                  if (!ccOrReviewer) {
+                    // User posting this review isn't currently in the reviewer or CC list,
+                    // isn't being explicitly added, and isn't voting on any label.
+                    // Automatically CC them on this change so they receive replies.
+                    logger.atFine().log("CCing calling user");
+                    ReviewerModification selfAddition =
+                        reviewerModifier.ccCurrentUser(revision.getUser(), revision);
+                    selfAddition.op.suppressEmail();
+                    selfAddition.op.suppressEvent();
+                    bu.addOp(revision.getChange().getId(), selfAddition.op);
+                  }
+
+                  // Add WorkInProgressOp if requested.
+                  if ((input.ready || input.workInProgress)
+                      && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+                    logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
+                    WorkInProgressOp wipOp =
+                        workInProgressOpFactory.create(
+                            input.workInProgress, new WorkInProgressOp.Input());
+                    wipOp.suppressEmail();
+                    bu.addOp(revision.getChange().getId(), wipOp);
+                  }
+
+                  // Add the review ops.
+                  logger.atFine().log("posting review");
+                  PostReviewOp postReviewOp =
+                      postReviewOpFactory.create(
+                          projectState,
+                          revision.getPatchSet().id(),
+                          input,
+                          revision.getAccountId());
+                  bu.addOp(revision.getChange().getId(), postReviewOp);
+
+                  // Adjust the attention set based on the input
+                  replyAttentionSetUpdates.updateAttentionSetOnPostReview(
+                      bu, postReviewOp, revision.getNotes(), input, revision.getUser());
+
+                  return bu.execute();
+                }
+              }
+            })
+        .call();
+  }
+
   private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
     return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index ddec6bd..91f1575 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -174,7 +174,7 @@
       String commitMessage,
       Instant timestamp,
       String committerEmail)
-      throws IOException, BadRequestException, ResourceConflictException {
+      throws IOException, BadRequestException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
diff --git a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
deleted file mode 100644
index fe28d6f..0000000
--- a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2014 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.schema;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedConfigFile;
-
-/** Preferences for user accounts during schema migrations. */
-class VersionedAccountPreferences extends VersionedConfigFile {
-  static final String PREFERENCES = "preferences.config";
-
-  static VersionedAccountPreferences forUser(Account.Id id) {
-    return new VersionedAccountPreferences(RefNames.refsUsers(id));
-  }
-
-  static VersionedAccountPreferences forDefault() {
-    return new VersionedAccountPreferences(RefNames.REFS_USERS_DEFAULT);
-  }
-
-  protected VersionedAccountPreferences(String ref) {
-    super(ref, PREFERENCES, "Updated preferences\n");
-  }
-}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index bb1f25f..3fa4157 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -94,6 +94,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.SubmissionExecutor;
@@ -277,6 +278,7 @@
 
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final InternalUser.Factory internalUserFactory;
   private final MergeSuperSet mergeSuperSet;
   private final MergeValidators.Factory mergeValidatorsFactory;
@@ -316,6 +318,7 @@
   MergeOp(
       ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
       MergeValidators.Factory mergeValidatorsFactory,
@@ -337,6 +340,7 @@
       @GerritServerConfig Config config) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.internalUserFactory = internalUserFactory;
     this.mergeSuperSet = mergeSuperSet;
     this.mergeValidatorsFactory = mergeValidatorsFactory;
@@ -558,8 +562,7 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(
-                changeDataFactory, dryrun, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         @SuppressWarnings("unused")
         var unused =
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 02afedb..2402357 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -24,9 +24,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
@@ -40,16 +39,16 @@
 
   @Singleton
   public static class Factory {
-    private final ChangeData.Factory changeDataFactory;
+    private final BatchUpdates batchUpdates;
     private final SubscriptionGraph.Factory subscriptionGraphFactory;
     private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
-        ChangeData.Factory changeDataFactory,
+        BatchUpdates batchUpdates,
         SubscriptionGraph.Factory subscriptionGraphFactory,
         SubmoduleCommits.Factory submoduleCommitsFactory) {
-      this.changeDataFactory = changeDataFactory;
+      this.batchUpdates = batchUpdates;
       this.subscriptionGraphFactory = subscriptionGraphFactory;
       this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
@@ -58,7 +57,7 @@
         Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
       return new SubmoduleOp(
-          changeDataFactory,
+          batchUpdates,
           updatedBranches,
           orm,
           subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
@@ -66,7 +65,7 @@
     }
   }
 
-  private final ChangeData.Factory changeDataFactory;
+  private final BatchUpdates batchUpdates;
   private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
   private final MergeOpRepoManager orm;
   private final SubscriptionGraph subscriptionGraph;
@@ -74,12 +73,12 @@
   private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
-      ChangeData.Factory changeDataFactory,
+      BatchUpdates batchUpdates,
       Map<BranchNameKey, ReceiveCommand> updatedBranches,
       MergeOpRepoManager orm,
       SubscriptionGraph subscriptionGraph,
       SubmoduleCommits submoduleCommits) {
-    this.changeDataFactory = changeDataFactory;
+    this.batchUpdates = batchUpdates;
     this.updatedBranches = updatedBranches;
     this.orm = orm;
     this.subscriptionGraph = subscriptionGraph;
@@ -115,8 +114,7 @@
         }
       }
       try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
-        BatchUpdate.execute(
-            changeDataFactory,
+        batchUpdates.execute(
             orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
             ImmutableList.of(),
             dryrun);
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 0e29a4e..813246c 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
@@ -35,7 +34,6 @@
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multiset;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -51,9 +49,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
@@ -73,12 +68,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.LimitExceededException;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -91,10 +81,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.TreeMap;
-import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -141,151 +129,6 @@
     BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
-  public static class Result {
-    private final ChangeData.Factory changeDataFactory;
-    private final Map<Change.Id, ChangeData> changeDatas;
-
-    private Result(ChangeData.Factory changeDataFactory) {
-      this(changeDataFactory, new HashMap<>());
-    }
-
-    private Result(ChangeData.Factory changeDataFactory, Map<Change.Id, ChangeData> changeDatas) {
-      this.changeDataFactory = changeDataFactory;
-      this.changeDatas = changeDatas;
-    }
-
-    /**
-     * Returns the updated {@link ChangeData} for the given project and change ID.
-     *
-     * <p>If the requested {@link ChangeData} was already loaded after the {@link BatchUpdate} has
-     * been executed the cached {@link ChangeData} instance is returned, otherwise the requested
-     * {@link ChangeData} is loaded and put into the cache.
-     */
-    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
-      return changeDatas.computeIfAbsent(
-          changeId, id -> changeDataFactory.create(projectName, changeId));
-    }
-  }
-
-  @CanIgnoreReturnValue
-  public static Result execute(
-      ChangeData.Factory changeDataFactory,
-      Collection<BatchUpdate> updates,
-      ImmutableList<BatchUpdateListener> listeners,
-      boolean dryrun)
-      throws UpdateException, RestApiException {
-    requireNonNull(listeners);
-    if (updates.isEmpty()) {
-      return new Result(changeDataFactory);
-    }
-
-    checkDifferentProject(updates);
-
-    try {
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
-      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
-      try {
-        for (BatchUpdate u : updates) {
-          u.executeUpdateRepo();
-        }
-        notifyAfterUpdateRepo(listeners);
-        for (BatchUpdate u : updates) {
-          changesHandles.add(u.executeChangeOps(listeners, dryrun));
-        }
-        for (ChangesHandle h : changesHandles) {
-          h.execute();
-          if (h.requiresReindex()) {
-            indexFutures.addAll(h.startIndexFutures());
-          }
-        }
-        notifyAfterUpdateRefs(listeners);
-        notifyAfterUpdateChanges(listeners);
-      } finally {
-        for (ChangesHandle h : changesHandles) {
-          h.close();
-        }
-      }
-
-      Map<Change.Id, ChangeData> changeDatas =
-          Futures.allAsList(indexFutures).get().stream()
-              // filter out null values that were returned for change deletions
-              .filter(Objects::nonNull)
-              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates.forEach(BatchUpdate::fireRefChangeEvents);
-
-      if (!dryrun) {
-        for (BatchUpdate u : updates) {
-          u.executePostOps(changeDatas);
-        }
-      }
-
-      return new Result(changeDataFactory, changeDatas);
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-      return new Result(changeDataFactory);
-    }
-  }
-
-  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRepos();
-    }
-  }
-
-  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRefs();
-    }
-  }
-
-  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateChanges();
-    }
-  }
-
-  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
-    Multiset<Project.NameKey> projectCounts =
-        updates.stream().map(u -> u.project).collect(toImmutableMultiset());
-    checkArgument(
-        projectCounts.entrySet().size() == updates.size(),
-        "updates must all be for different projects, got: %s",
-        projectCounts);
-  }
-
-  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
-    // Convert common non-REST exception types with user-visible messages to corresponding REST
-    // exception types.
-    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } else if (e instanceof NoSuchChangeException
-        || e instanceof NoSuchRefException
-        || e instanceof NoSuchProjectException) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } else if (e instanceof CommentsRejectedException) {
-      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
-      // status code and it's isolated in monitoring.
-      throw new BadRequestException(e.getMessage(), e);
-    }
-
-    Throwables.throwIfUnchecked(e);
-
-    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
-    // ResourceConflictException to indicate an atomic update failure.
-    Throwables.throwIfInstanceOf(e, UpdateException.class);
-    Throwables.throwIfInstanceOf(e, RestApiException.class);
-
-    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
-    throw new UpdateException(e);
-  }
-
   class ContextImpl implements Context {
     private final CurrentUser contextUser;
 
@@ -441,6 +284,7 @@
     DELETED
   }
 
+  private final BatchUpdates batchUpdates;
   private final GitRepositoryManager repoManager;
   private final AccountCache accountCache;
   private final ChangeData.Factory changeDataFactory;
@@ -477,6 +321,7 @@
 
   @Inject
   BatchUpdate(
+      BatchUpdates batchUpdates,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       AccountCache accountCache,
@@ -493,6 +338,7 @@
       @Assisted CurrentUser user,
       @Assisted Instant when) {
     this.gerritConfig = gerritConfig;
+    this.batchUpdates = batchUpdates;
     this.repoManager = repoManager;
     this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
@@ -517,13 +363,14 @@
   }
 
   @CanIgnoreReturnValue
-  public Result execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    return execute(changeDataFactory, ImmutableList.of(this), ImmutableList.of(listener), false);
+  public BatchUpdates.Result execute(BatchUpdateListener listener)
+      throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(listener), false);
   }
 
   @CanIgnoreReturnValue
-  public Result execute() throws UpdateException, RestApiException {
-    return execute(changeDataFactory, ImmutableList.of(this), ImmutableList.of(), false);
+  public BatchUpdates.Result execute() throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(), false);
   }
 
   public boolean isExecuted() {
@@ -676,7 +523,7 @@
     return this;
   }
 
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
+  void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
@@ -718,7 +565,7 @@
         && gerritConfig.getBoolean("index", "indexChangesAsync", false);
   }
 
-  private void fireRefChangeEvents() {
+  void fireRefChangeEvents() {
     batchRefUpdate.forEach(
         (projectName, bru) -> gitRefUpdated.fire(projectName, bru, getAccount().orElse(null)));
   }
@@ -735,7 +582,7 @@
     }
   }
 
-  private class ChangesHandle implements AutoCloseable {
+  class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
     private final Map<Change.Id, ChangeResult> results;
@@ -820,7 +667,7 @@
     }
   }
 
-  private ChangesHandle executeChangeOps(
+  ChangesHandle executeChangeOps(
       ImmutableList<BatchUpdateListener> batchUpdateListeners, boolean dryrun) throws Exception {
     logDebug("Executing change ops");
     initRepository();
@@ -861,9 +708,11 @@
           ctx.distinctUpdates.values().forEach(changeUpdates::add);
           ctx = newChangeContext(opData.user(), id);
         }
+        Class<? extends BatchUpdateOp> opClass = opData.op().getClass();
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                opData.getClass().getSimpleName() + "#updateChange",
+                (opClass.isAnonymousClass() ? opClass.getName() : opClass.getSimpleName())
+                    + "#updateChange",
                 Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= opData.op().updateChange(ctx);
           deleted |= ctx.deleted;
@@ -948,7 +797,7 @@
     return new ChangeContextImpl(contextUser, notes);
   }
 
-  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+  void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
     for (OpData<BatchUpdateOp> opData : ops.values()) {
       PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
diff --git a/java/com/google/gerrit/server/update/BatchUpdates.java b/java/com/google/gerrit/server/update/BatchUpdates.java
new file mode 100644
index 0000000..2f9ef84
--- /dev/null
+++ b/java/com/google/gerrit/server/update/BatchUpdates.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2024 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multiset;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.LimitExceededException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate.ChangesHandle;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+@Singleton
+public class BatchUpdates {
+  public class Result {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    private Result() {
+      this(new HashMap<>());
+    }
+
+    private Result(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    /**
+     * Returns the updated {@link ChangeData} for the given project and change ID.
+     *
+     * <p>If the requested {@link ChangeData} was already loaded after the {@link BatchUpdate} has
+     * been executed the cached {@link ChangeData} instance is returned, otherwise the requested
+     * {@link ChangeData} is loaded and put into the cache.
+     */
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  BatchUpdates(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @CanIgnoreReturnValue
+  public Result execute(
+      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
+      throws UpdateException, RestApiException {
+    requireNonNull(listeners);
+    if (updates.isEmpty()) {
+      return new Result();
+    }
+
+    checkDifferentProject(updates);
+
+    try {
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
+      try {
+        for (BatchUpdate u : updates) {
+          u.executeUpdateRepo();
+        }
+        notifyAfterUpdateRepo(listeners);
+        for (BatchUpdate u : updates) {
+          changesHandles.add(u.executeChangeOps(listeners, dryrun));
+        }
+        for (ChangesHandle h : changesHandles) {
+          h.execute();
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
+        }
+        notifyAfterUpdateRefs(listeners);
+        notifyAfterUpdateChanges(listeners);
+      } finally {
+        for (ChangesHandle h : changesHandles) {
+          h.close();
+        }
+      }
+
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates.forEach(BatchUpdate::fireRefChangeEvents);
+
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps(changeDatas);
+        }
+      }
+
+      return new Result(changeDatas);
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+      return new Result();
+    }
+  }
+
+  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRepos();
+    }
+  }
+
+  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRefs();
+    }
+  }
+
+  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateChanges();
+    }
+  }
+
+  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+    Multiset<Project.NameKey> projectCounts =
+        updates.stream().map(u -> u.getProject()).collect(toImmutableMultiset());
+    checkArgument(
+        projectCounts.entrySet().size() == updates.size(),
+        "updates must all be for different projects, got: %s",
+        projectCounts);
+  }
+
+  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } else if (e instanceof CommentsRejectedException) {
+      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
+      // status code and it's isolated in monitoring.
+      throw new BadRequestException(e.getMessage(), e);
+    }
+
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index d9af527..c5621ed 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -479,6 +479,12 @@
                 String actionName = opts.actionName().orElse("N/A");
                 String cause = formatCause(t);
 
+                // Do not retry if retrying was already done and failed.
+                if (Throwables.getCausalChain(t).stream()
+                    .anyMatch(RetryException.class::isInstance)) {
+                  return false;
+                }
+
                 // exceptionPredicate checks for temporary errors for which the operation should be
                 // retried (e.g. LockFailure). The retry has good chances to succeed.
                 if (exceptionPredicate.test(t)) {
@@ -596,6 +602,9 @@
             actionType,
             opts.actionName().orElse("N/A"),
             listener.getOriginalCause().map(this::formatCause).orElse("_unknown"));
+
+        // Re-throw the RetryException so that retrying is not re-attempted on an outer level.
+        throw e;
       }
       if (e.getCause() != null) {
         Throwables.throwIfUnchecked(e.getCause());
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index bab6ddd..762de57 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -16,23 +16,22 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
 public class SubmissionExecutor {
-  private final ChangeData.Factory changeDataFactory;
+  private final BatchUpdates batchUpdates;
   private final ImmutableList<SubmissionListener> submissionListeners;
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
   public SubmissionExecutor(
-      ChangeData.Factory changeDataFactory,
+      BatchUpdates batchUpdates,
       boolean dryrun,
       ImmutableList<SubmissionListener> submissionListeners) {
-    this.changeDataFactory = changeDataFactory;
+    this.batchUpdates = batchUpdates;
     this.dryrun = dryrun;
     this.submissionListeners = submissionListeners;
     if (dryrun) {
@@ -63,7 +62,7 @@
                     .map(Optional::get)
                     .collect(Collectors.toList()))
             .build();
-    BatchUpdate.execute(changeDataFactory, updates, listeners, dryrun);
+    batchUpdates.execute(updates, listeners, dryrun);
   }
 
   /**
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 65954e4..8626a32 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -52,6 +52,7 @@
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
+import com.github.rholder.retry.RetryException;
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -2281,9 +2282,12 @@
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
-    assertThrows(
-        LockFailureException.class,
-        () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    StorageException exception =
+        assertThrows(
+            StorageException.class,
+            () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(LockFailureException.class);
     assertThat(bgCounter.get()).isEqualTo(status.size());
 
     Account updatedAccount = accounts.get(admin.id()).get().account();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
index 3ead608..b4c4595 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -49,16 +50,17 @@
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+      bind(AccountListenersITValidator.class).to(Validator.class);
       DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
     }
   }
 
-  Validator validator;
+  AccountListenersITValidator validator;
   Listener listener;
 
   @Before
   public void setUp() {
-    validator = plugin.getSysInjector().getInstance(Validator.class);
+    validator = plugin.getSysInjector().getInstance(AccountListenersITValidator.class);
 
     listener = plugin.getSysInjector().getInstance(Listener.class);
   }
@@ -128,8 +130,21 @@
     listener.assertNoMoreEvents();
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected interface AccountListenersITValidator extends AccountActivationValidationListener {
+    void failActivationValidations();
+
+    void failDeactivationValidations();
+
+    void assertNoMoreEvents();
+
+    void assertActivationValidation(int id);
+
+    void assertDeactivationValidation(int id);
+  }
+
   @Singleton
-  public static class Validator implements AccountActivationValidationListener {
+  public static final class Validator implements AccountListenersITValidator {
     private Integer lastIdActivationValidation;
     private Integer lastIdDeactivationValidation;
     private boolean failActivationValidations;
@@ -153,25 +168,30 @@
       }
     }
 
+    @Override
     public void failActivationValidations() {
       failActivationValidations = true;
     }
 
+    @Override
     public void failDeactivationValidations() {
       failDeactivationValidations = true;
     }
 
-    private void assertNoMoreEvents() {
+    @Override
+    public void assertNoMoreEvents() {
       assertThat(lastIdActivationValidation).isNull();
       assertThat(lastIdDeactivationValidation).isNull();
     }
 
-    private void assertActivationValidation(int id) {
+    @Override
+    public void assertActivationValidation(int id) {
       assertThat(lastIdActivationValidation).isEqualTo(id);
       lastIdActivationValidation = null;
     }
 
-    private void assertDeactivationValidation(int id) {
+    @Override
+    public void assertDeactivationValidation(int id) {
       assertThat(lastIdDeactivationValidation).isEqualTo(id);
       lastIdDeactivationValidation = null;
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 7449a5c..efc7e0f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -51,11 +53,9 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
-import java.io.IOException;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -593,7 +593,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -647,9 +647,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Cannot assign external ID \"username:foo\" to account");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -798,29 +796,22 @@
         "Create Test Account",
         accountId,
         u -> u.addExternalId(externalIdFactory.create(mailExtIdKey, accountId)));
-
     accountManager.link(accountId, authRequestFactory.createForEmail(email1));
+    int initialCommits = countExternalIdsCommits();
 
-    int initialCommits;
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      initialCommits = getCommitsInExternalIds(git, allUsersRepo);
+    accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
 
-      accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
-    }
-    // Reopen the repo again - this is required for git.log() operations (otherwise, git.log()
-    // returns unmodified history on google internal infra).
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      int afterUpdateCommits = getCommitsInExternalIds(git, allUsersRepo);
-      assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
-    }
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
   }
 
-  private static int getCommitsInExternalIds(Git git, Repository allUsersRepo)
-      throws GitAPIException, IOException {
-    ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
-    return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+    }
   }
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 0309646..984b32d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
@@ -34,12 +36,8 @@
 
   @Test
   public void fromAccountUpdate() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
-      String sha1 =
-          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
-      assertThat(sha1).isEqualTo(messageId);
-    }
+    String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+    validateAccountUpdateMessageId(messageId, admin.id());
   }
 
   @Test
@@ -78,4 +76,13 @@
             messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
         .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
   }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected void validateAccountUpdateMessageId(String messageId, Account.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 8d30d45..ab74a4a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -268,6 +268,7 @@
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
+    assertThat(c.currentRevisionNumber).isEqualTo(r.getPatchSetId().get());
 
     assertThat(c.owner._accountId).isEqualTo(admin.id().get());
     assertThat(c.owner.name).isNull();
@@ -513,7 +514,7 @@
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
     ReviewResult result = gApi.changes().id(changeId).current().review(in);
-    assertThat(result.changeInfo).isNull();
+    assertThat(result.changeInfo).isNotNull();
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 314dc44..4d8566d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -24,6 +24,7 @@
 import static java.util.stream.Collectors.toList;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -64,6 +65,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -154,6 +156,10 @@
       @Override
       public void configure() {
         CommentValidator mockCommentValidator = mock(CommentValidator.class);
+
+        // by default return no validation errors
+        when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+
         bind(CommentValidator.class)
             .annotatedWith(Exports.named(mockCommentValidator.getClass()))
             .toInstance(mockCommentValidator);
@@ -759,6 +765,26 @@
   }
 
   @Test
+  public void currentRevisionNumberIsSetOnReturnedChangeInfo() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.dislike()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(1);
+    amendChange(r.getChangeId());
+    changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+
+    // Check that the current revision number is also returned when list changes options are
+    // requested.
+    ReviewInput reviewInput = ReviewInput.approve();
+    reviewInput.responseFormatOptions =
+        ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+    changeInfo = gApi.changes().id(r.getChangeId()).current().review(reviewInput).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+  }
+
+  @Test
   public void submitRulesAreInvokedOnlyOnce() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 576a921..13382ef 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -110,7 +110,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 9723df1..b9c07a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -37,6 +37,7 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.RetryException;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -1190,7 +1191,11 @@
     testMetricMaker.reset();
 
     Throwable thrown = assertThrows(StorageException.class, () -> submit(id, input));
-    assertThat(thrown.getCause()).hasMessageThat().contains("missing from ChangeSet[][]");
+    assertThat(thrown.getCause()).hasMessageThat().contains("Computing mergeSuperset has failed");
+    assertThat(thrown.getCause()).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(thrown.getCause().getCause().getCause())
+        .hasMessageThat()
+        .contains("missing from ChangeSet[][]");
 
     // We retried more than once before giving up
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index a9e3cf6..9ed6d15 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -33,7 +33,7 @@
     TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
     assertThat(info.id).isNotNull();
     Long.parseLong(info.id, 16);
-    assertThat(info.command).isEqualTo("Log File Compressor");
+    assertThat(info.command).isEqualTo("Log File Manager");
     assertThat(info.startTime).isNotNull();
   }
 
@@ -49,7 +49,7 @@
         newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         return info.id;
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 2aa350e..ab3689b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -36,7 +36,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index 674ca79..cad0875 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -34,7 +34,7 @@
     assertThat(result).isNotEmpty();
     boolean foundLogFileCompressorTask = false;
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         foundLogFileCompressorTask = true;
       }
       assertThat(info.id).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index e62cb2b..809cee9 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -150,7 +150,7 @@
       @Override
       public void configure() {
         // Forwarder.delegate is empty on start to protect test listener from non test tasks
-        // (such as the "Log File Compressor") interference
+        // (such as the "Log File Manager") interference
         forwarder = new ForwardingListener(); // Only gets bound once for all tests
         bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
       }
@@ -161,7 +161,7 @@
   public void setupExecutorAndForwarder() throws InterruptedException {
     executor = workQueue.createQueue(1, "TaskListeners");
 
-    // "Log File Compressor"s are likely running and will interfere with tests
+    // "Log File Manager"s are likely running and will interfere with tests
     while (0 != workQueue.getTasks().size()) {
       for (Task<?> t : workQueue.getTasks()) {
         @SuppressWarnings("unused")
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index b2be85f..573c050 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -110,12 +110,12 @@
         outputPath);
     try (InputStream fi = Files.newInputStream(outputPath);
         InputStream bi = new BufferedInputStream(fi);
-        ArchiveInputStream archive = archiveStreamForFormat(bi, format)) {
+        ArchiveInputStream<?> archive = archiveStreamForFormat(bi, format)) {
       assertEntries(archive);
     }
   }
 
-  private ArchiveInputStream archiveStreamForFormat(InputStream bi, String format)
+  private ArchiveInputStream<?> archiveStreamForFormat(InputStream bi, String format)
       throws IOException {
     switch (format) {
       case "zip":
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index 0fe4fad..43b86cb 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/pgm/http/jetty",
         "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/securestore/testing",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
new file mode 100644
index 0000000..b3f59cc
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 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.pgm.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LogFileManagerTest {
+
+  @Test
+  public void testLogFilePattern() throws Exception {
+    List<String> filenamesWithDate =
+        List.of(
+            "error_log.2024-01-01",
+            "error_log.2024-01-01.gz",
+            "error_log.json.2024-01-01",
+            "error_log.json.2024-01-01.gz",
+            "sshd_log.2024-01-01",
+            "httpd_log.2024-01-01");
+
+    List<String> filenamesWithoutDate =
+        List.of(
+            "error_log",
+            "error_log.gz",
+            "error_log.json",
+            "error_log.json.gz",
+            "sshd_log",
+            "httpd_log");
+
+    LogFileManager manager = new LogFileManager(new SitePaths(Path.of("/gerrit")), new Config());
+    Instant expected = Instant.parse("2024-01-01T00:00:00.00Z");
+    for (String filename : filenamesWithDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).get()).isEqualTo(expected);
+    }
+
+    for (String filename : filenamesWithoutDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).isEmpty()).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
index 772f4b8..b149d09 100644
--- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -130,6 +130,27 @@
   }
 
   @Test
+  public void userPreferencesProto_falseValueReturnsAsNull() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(
+                UserPreferences.EditPreferencesInfo.newBuilder()
+                    .setTabSize(17)
+                    .setHideTopMenu(false)
+                    .setHideLineNumbers(false)
+                    .setAutoCloseBrackets(true))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(17);
+    assertThat(edit.hideTopMenu).isNull();
+    assertThat(edit.hideLineNumbers).isNull();
+    assertThat(edit.autoCloseBrackets).isTrue();
+  }
+
+  @Test
   public void defaultPreferences_acceptingGitConfig() throws Exception {
     Config cfg = new Config();
     cfg.fromText("[general]\n\tchangesPerPage = 19");
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index c6ca3e4..8cfcdbd 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -118,6 +118,51 @@
   }
 
   @Test
+  public void generalPreferencesInfo_toProtoTrimsMyMenuSpaces() {
+    GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+    info.my =
+        ImmutableList.of(
+            new com.google.gerrit.extensions.client.MenuItem(
+                " name1 ", " url1 ", " target1 ", " id1 "),
+            new com.google.gerrit.extensions.client.MenuItem(null, " url2 ", null, null));
+    UserPreferences.GeneralPreferencesInfo resProto = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(resProto)
+        .isEqualTo(
+            UserPreferences.GeneralPreferencesInfo.newBuilder()
+                .addAllMyMenuItems(
+                    ImmutableList.of(
+                        MenuItem.newBuilder()
+                            .setUrl("url1")
+                            .setName("name1")
+                            .setTarget("target1")
+                            .setId("id1")
+                            .build(),
+                        MenuItem.newBuilder().setUrl("url2").build()))
+                .build());
+  }
+
+  @Test
+  public void generalPreferencesInfo_fromProtoTrimsMyMenuSpaces() {
+    UserPreferences.GeneralPreferencesInfo originalProto =
+        UserPreferences.GeneralPreferencesInfo.newBuilder()
+            .addAllMyMenuItems(
+                ImmutableList.of(
+                    MenuItem.newBuilder()
+                        .setName(" name1 ")
+                        .setUrl(" url1 ")
+                        .setTarget(" target1 ")
+                        .setId(" id1 ")
+                        .build(),
+                    MenuItem.newBuilder().setUrl(" url2 ").build()))
+            .build();
+    GeneralPreferencesInfo info = GeneralPreferencesInfoConverter.fromProto(originalProto);
+    assertThat(info.my)
+        .containsExactly(
+            new com.google.gerrit.extensions.client.MenuItem("name1", "url1", "target1", "id1"),
+            new com.google.gerrit.extensions.client.MenuItem(null, "url2", null, null));
+  }
+
+  @Test
   public void generalPreferencesInfo_emptyJavaToProto() {
     GeneralPreferencesInfo info = new GeneralPreferencesInfo();
     UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index 7ed2391..f335201 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 345681d..a602ee9 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -12,11 +12,13 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
+        "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib/guice",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index f7a2afa..79a688c 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,6 +22,9 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryListener;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -37,7 +40,10 @@
 import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.Sequences;
@@ -53,6 +59,7 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
@@ -61,12 +68,17 @@
 import com.google.inject.name.Named;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -107,6 +119,8 @@
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private InternalUser.Factory internalUserFactory;
   @Inject private AbandonOp.Factory abandonOpFactory;
+  @Inject @GerritPersonIdent private PersonIdent serverIdent;
+  @Inject private RetryHelper retryHelper;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -566,6 +580,245 @@
     assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
   }
 
+  @Test
+  public void lockFailureOnConcurrentUpdate() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+              doneBackgroundUpdate.set(true);
+            } catch (Exception e) {
+              // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+              // when doneBackgroundUpdate is checked.
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it fails with LOCK_FAILURE due to the concurrent update.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () -> {
+              try (BatchUpdate bu =
+                  batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+                bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                bu.execute(listener);
+              }
+            });
+    assertThat(exception).hasCauseThat().isInstanceOf(LockFailureException.class);
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
+  @Test
+  public void useRetryHelperToRetryOnLockFailure() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            if (!doneBackgroundUpdate.getAndSet(true)) {
+              try (RevWalk rw = new RevWalk(repo.getRepository())) {
+                RevCommit old = rw.parseCommit(metaId);
+                RevCommit commit =
+                    repo.commit()
+                        .parent(old)
+                        .author(serverIdent)
+                        .committer(serverIdent)
+                        .setTopLevelTree(old.getTree())
+                        .message("Concurrent Update\n\nPatch-Set: 1")
+                        .create();
+                RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+                ru.setExpectedOldObjectId(metaId);
+                ru.setNewObjectId(commit);
+                ru.update();
+                RefUpdateUtil.checkResult(ru);
+              } catch (Exception e) {
+                // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+                // when doneBackgroundUpdate is checked.
+              }
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it succeeds due to retrying despite the LOCK_FAILURE on the
+    // first attempt.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+
+    @SuppressWarnings("unused")
+    var unused =
+        retryHelper
+            .changeUpdate(
+                "batchUpdate",
+                updateFactory -> {
+                  try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
+                    bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                    bu.execute(listener);
+                  }
+                  return null;
+                })
+            .call();
+
+    // Check that the concurrent update was done.
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the BatchUpdate updated the change.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.ABANDONED);
+  }
+
+  @Test
+  public void noRetryingOnOuterLevelIfRetryingWasAlreadyDoneOnInnerLevel() throws Exception {
+    Change.Id changeId = createChange();
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean backgroundFailure = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on all attempts.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              String changeMetaRef = RefNames.changeMetaRef(changeId);
+              ObjectId metaId = repo.getRepository().exactRef(changeMetaRef).getObjectId();
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(changeMetaRef);
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+            } catch (Exception e) {
+              backgroundFailure.set(true);
+            }
+
+            return bru;
+          }
+        };
+
+    AtomicInteger innerRetryOnExceptionCounter = new AtomicInteger();
+    AtomicInteger outerRetryOnExceptionCounter = new AtomicInteger();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () ->
+                // Outer level retrying. We expect that no retrying is happens here because retrying
+                // is already done on the inner level.
+                retryHelper
+                    .action(
+                        ActionType.CHANGE_UPDATE,
+                        "batchUpdate",
+                        () ->
+                            // Inner level retrying. We expect that retrying happens here.
+                            retryHelper
+                                .changeUpdate(
+                                    "batchUpdate",
+                                    updateFactory -> {
+                                      try (BatchUpdate bu =
+                                          updateFactory.create(
+                                              project, user.get(), TimeUtil.now())) {
+                                        bu.addOp(
+                                            changeId, abandonOpFactory.create(null, "abandon"));
+                                        bu.execute(listener);
+                                      }
+                                      return null;
+                                    })
+                                .listener(
+                                    new RetryListener() {
+                                      @Override
+                                      public <V> void onRetry(Attempt<V> attempt) {
+                                        if (attempt.hasException()) {
+                                          @SuppressWarnings("unused")
+                                          var unused =
+                                              innerRetryOnExceptionCounter.incrementAndGet();
+                                        }
+                                      }
+                                    })
+                                .call())
+                    // give it enough time to potentially retry multiple times when each retry also
+                    // does retrying
+                    .defaultTimeoutMultiplier(5)
+                    .listener(
+                        new RetryListener() {
+                          @Override
+                          public <V> void onRetry(Attempt<V> attempt) {
+                            if (attempt.hasException()) {
+                              @SuppressWarnings("unused")
+                              var unused = outerRetryOnExceptionCounter.incrementAndGet();
+                            }
+                          }
+                        })
+                    .call());
+    assertThat(backgroundFailure.get()).isFalse();
+
+    // Check that retrying was done on the inner level.
+    assertThat(innerRetryOnExceptionCounter.get()).isGreaterThan(1);
+
+    // Check that there was no retrying on the outer level since retrying was already done on the
+    // inner level.
+    // We expect 1 because RetryListener#onRetry is invoked before the rejection predicate and stop
+    // strategies are applied (i.e. before RetryHelper decides whether retrying should be done).
+    assertThat(outerRetryOnExceptionCounter.get()).isEqualTo(1);
+
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(UpdateException.class);
+    assertThat(exception.getCause().getCause())
+        .hasCauseThat()
+        .isInstanceOf(LockFailureException.class);
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
   private Change.Id createChange() throws Exception {
     Change.Id id = Change.id(sequences.nextChangeId());
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
diff --git a/plugins/replication b/plugins/replication
index aac2528..2f6c7ce 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit aac252809094b8e4d4e26d69dab75a23d2da1770
+Subproject commit 2f6c7ceeb0cc50bc73d018cd9f990392d58804ab
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index f406511..5b2f951 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -493,6 +493,7 @@
     // TODO: Do we still need docOnly bindings?
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.SAVE_COMMENT, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
       this.getChangeModel().navigateToChangeResetReload()
     );
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index bc0e015..613f907 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -241,6 +241,20 @@
           display: inline-block;
           margin-left: var(--spacing-s);
         }
+        /* actions-shown-on-collapsed are shown only when .actions is hidden
+          and vice versa. */
+        tr.container td .summary-cell .actions-shown-on-collapsed,
+        tr.container.collapsed:focus-within
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        tr.container.collapsed:hover
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        :host(.dropdown-open) tr td .summary-cell .actions-shown-on-collapsed {
+          display: none;
+        }
         tr.container.collapsed td .summary-cell .message {
           color: var(--deemphasized-text-color);
         }
@@ -248,6 +262,10 @@
         tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
+        tr.container.collapsed td .summary-cell .actions-shown-on-collapsed {
+          display: inline-block;
+          margin-left: var(--spacing-s);
+        }
         tr.detailsRow.collapsed {
           display: none;
         }
@@ -278,6 +296,7 @@
         td .summary-cell .tag.brown {
           background-color: var(--tag-brown);
         }
+        .actions-shown-on-collapsed gr-checks-action,
         .actions gr-checks-action,
         .actions gr-dropdown {
           /* Fitting a 28px button into 20px line-height. */
@@ -549,24 +568,31 @@
     const disabledItems = overflowItems
       .filter(action => action.disabled)
       .map(action => action.id);
-    return html`<div class="actions">
-      ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
-      <gr-dropdown
-        id="moreActions"
-        link=""
-        vertical-offset="32"
-        horizontal-align="right"
-        @tap-item=${this.handleAction}
-        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
-          this.classList.toggle('dropdown-open', e.detail.value)}
-        ?hidden=${overflowItems.length === 0}
-        .items=${overflowItems}
-        .disabledIds=${disabledItems}
-      >
-        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
-        <span id="moreMessage">More</span>
-      </gr-dropdown>
-    </div>`;
+    return html` ${when(
+        fixAction,
+        () =>
+          html`<div class="actions-shown-on-collapsed">
+            ${this.renderAction(fixAction)}
+          </div> `
+      )}
+      <div class="actions">
+        ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
+        <gr-dropdown
+          id="moreActions"
+          link=""
+          vertical-offset="32"
+          horizontal-align="right"
+          @tap-item=${this.handleAction}
+          @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+            this.classList.toggle('dropdown-open', e.detail.value)}
+          ?hidden=${overflowItems.length === 0}
+          .items=${overflowItems}
+          .disabledIds=${disabledItems}
+        >
+          <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
@@ -585,24 +611,6 @@
     ></gr-checks-action>`;
   }
 
-  renderPrimaryActions() {
-    const primaryActions = (this.result?.actions ?? []).slice(0, 2);
-    if (primaryActions.length === 0) return;
-    return html`
-      <div class="primaryActions">${primaryActions.map(this.renderAction)}</div>
-    `;
-  }
-
-  renderSecondaryActions() {
-    const secondaryActions = (this.result?.actions ?? []).slice(2);
-    if (secondaryActions.length === 0) return;
-    return html`
-      <div class="secondaryActions">
-        ${secondaryActions.map(this.renderAction)}
-      </div>
-    `;
-  }
-
   renderTag(tag: Tag) {
     return html`<button
       class="tag ${tag.color}"
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 58b939c..89c7fd7 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -211,7 +211,6 @@
   }
 
   private renderActions() {
-    if (!this.isExpanded) return nothing;
     return html`<div class="actions">
       ${this.renderShowFixButton()}${this.renderPleaseFixButton()}
     </div>`;
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index b913c87..5aa266c 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -31,31 +31,42 @@
     assert.shadowDom.equal(
       element,
       `
-      <div class="container font-normal warning">
-        <div class="header">
-          <div class="icon">
-            <gr-icon icon="warning" filled></gr-icon>
-          </div>
-          <div class="name">
-            <gr-hovercard-run> </gr-hovercard-run>
-            <div class="name" role="button" tabindex="0">FAKE Super Check</div>
-          </div>
-          <div class="summary">We think that you could improve this.</div>
-          <div class="message">
-            There is a lot to be said. A lot. I say, a lot.
+        <div class="container font-normal warning">
+          <div class="header">
+            <div class="icon">
+              <gr-icon icon="warning" filled></gr-icon>
+            </div>
+            <div class="name">
+              <gr-hovercard-run> </gr-hovercard-run>
+              <div class="name" role="button" tabindex="0">
+                FAKE Super Check
+              </div>
+            </div>
+            <div class="summary">We think that you could improve this.</div>
+            <div class="message">
+              There is a lot to be said. A lot. I say, a lot.
                 So please keep reading.
+            </div>
+            <div
+              aria-checked="false"
+              aria-label="Expand result row"
+              class="show-hide"
+              role="switch"
+              tabindex="0"
+            >
+              <gr-icon icon="expand_more"></gr-icon>
+            </div>
           </div>
-          <div aria-checked="false"
-               aria-label="Expand result row"
-               class="show-hide"
-               role="switch"
-               tabindex="0">
-            <gr-icon icon="expand_more"></gr-icon>
+          <div class="details">
+            <div class="actions">
+              <gr-checks-action
+                id="please-fix"
+                context="diff-fix"
+              ></gr-checks-action>
+            </div>
           </div>
         </div>
-        <div class="details"></div>
-      </div>
-    `
+      `
     );
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 98e9eba..368eb22 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -126,7 +126,11 @@
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+// 3 types of tokens
+// 1. predicate:expression (?:[^\s":]+:\s*[^\s"]+)
+// 2. quotes with anything inside "[^"]*"
+// 3. anything else like unfinished predicate [^\s"]+
+const TOKENIZE_REGEX = /(?:(?:[^\s":]+:\s*[^\s"]+)|[^\s"]+|"[^"]*")+\s*/g;
 
 export type SuggestionProvider = (
   predicate: string,
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index f67024f..bc8da05 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -256,6 +256,18 @@
       const s = await element.getSearchSuggestions('is:mergeab');
       assert.isEmpty(s);
     });
+
+    test('Autocompletes correctly second condition', async () => {
+      const s = await element.getSearchSuggestions('is:open me');
+      assert.equal(s[0].value, 'mergedafter:');
+    });
+
+    test('Autocomplete handles space before expression correctly', async () => {
+      // This previously suggested "mergedafter" (incorrectly) due to the
+      // leading space.
+      const s = await element.getSearchSuggestions('author: me');
+      assert.isEmpty(s);
+    });
   });
 
   [
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index bb5030c..fc18968 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -145,7 +145,7 @@
   if (replacements.length === 0) return undefined;
 
   return {
-    description: fix.description ?? `Fix provided by ${checkName}`,
+    description: fix.description || `Fix provided by ${checkName}`,
     fix_id: PROVIDED_FIX_ID,
     replacements,
   };
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index ead7dd01..3b5c108 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -93,6 +93,29 @@
     assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
   });
 
+  test('rectifyFix changes description when description is empty', () => {
+    const rectified = rectifyFix(
+      {
+        replacements: [
+          {
+            path: 'test-path',
+            range: {
+              start_line: 1,
+              end_line: 1,
+              start_character: 0,
+              end_character: 1,
+            } as CommentRange,
+            replacement: 'test-replacement-string',
+          },
+        ],
+        description: '',
+      },
+      'test-check-name'
+    );
+    assert.isDefined(rectified);
+    assert.equal(rectified?.description, 'Fix provided by test-check-name');
+  });
+
   test('sortAttemptChoices', () => {
     const unsorted: (AttemptChoice | undefined)[] = [
       3,