Merge "Increase contrast of +x/-y delta count text"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 47afdba..8509b1f 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1425,8 +1425,7 @@
 any of their groups is used.
 
 This limit applies not only to the link:cmd-query.html[`gerrit query`]
-command, but also to the web UI results pagination size in the new
-PolyGerrit UI and, limited to the full project list, in the old GWT UI.
+command, but also to the web UI results pagination size.
 
 
 [[capability_readAs]]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 8ad8669..1bf51a1 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -828,6 +828,49 @@
 +
 If 0 or negative, disk storage for the cache is disabled.
 
+[[cache.name.expireAfterWrite]]cache.<name>.expireAfterWrite::
++
+Duration after which a cached value will be evicted and not
+read anymore.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+Disabled by default.
+
+[[cache.name.refreshAfterWrite]]cache.<name>.refreshAfterWrite::
++
+Duration after which we asynchronously refresh the cached value.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory. Defaults to 15 minutes.
+
+[[cache.refreshThreadPoolSize]]cache.refreshThreadPoolSize::
++
+Number of threads that are available to refresh cached values that became
+out of date. This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory
++
+Refreshes will only be scheduled on this executor if the values are
+out of sync.
+The check if they are is cheap and always happens on the thread that
+inquires for a cached value.
++
+Defaults to 2.
+
 ==== [[cache_names]]Standard Caches
 
 cache `"accounts"`::
@@ -1125,22 +1168,6 @@
 +
 Default is true, enabled.
 
-[[cache.projects.checkFrequency]]cache.projects.checkFrequency::
-+
-How often project configuration should be checked for update from Git.
-Gerrit Code Review caches project access rules and configuration in
-memory, checking the refs/meta/config branch every checkFrequency
-minutes to see if a new revision should be loaded and used for future
-access. Values can be specified using standard time unit abbreviations
-('ms', 'sec', 'min', etc.).
-+
-If set to 0, checks occur every time, which may slow down operations.
-If set to 'disabled' or 'off', no check will ever be done.
-Administrators may force the cache to flush with
-link:cmd-flush-caches.html[gerrit flush-caches].
-+
-Default is 5 minutes.
-
 [[cache.projects.loadOnStartup]]cache.projects.loadOnStartup::
 +
 If the project cache should be loaded during server startup.
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7e6799be..3040348 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -64,6 +64,7 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
+* `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
 === Change
 
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 0ef6ad5..2d62608 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -483,7 +483,6 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setInt("cache", "projects", "checkFrequency", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 05992d4..0c3b7b0 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -246,7 +246,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private Injector createCfgInjector() {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 034e042e..57bec71 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -380,7 +380,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private String myVersion() {
diff --git a/java/com/google/gerrit/server/CacheRefreshExecutor.java b/java/com/google/gerrit/server/CacheRefreshExecutor.java
new file mode 100644
index 0000000..1a377c3
--- /dev/null
+++ b/java/com/google/gerrit/server/CacheRefreshExecutor.java
@@ -0,0 +1,28 @@
+// 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;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on the global {@link java.util.concurrent.ThreadPoolExecutor} used to refresh outdated
+ * values in caches.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheRefreshExecutor {}
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index df57629..745755b 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -31,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -114,7 +116,16 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      email.create(notify, changeNotes, ps, user, message, comments, null, labelDelta).sendAsync();
+      RepoView repoView;
+      try {
+        repoView = ctx.getRepoView();
+      } catch (IOException ex) {
+        throw new StorageException(
+            String.format("Repository %s not found", ctx.getProject().get()), ex);
+      }
+      email
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
     commentAdded.fire(
         changeNotes.getChange(),
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index 9d90d073..99db64e 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -29,6 +29,13 @@
   /** Set the time an element lives after last access before being expired. */
   CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
+  /**
+   * Set the time that an element will be refreshed after. Elements older than this but younger than
+   * {@link #expireAfterWrite(Duration)} will still be returned, but on access a task is queued to
+   * refresh their value asynchronously.
+   */
+  CacheBinding<K, V> refreshAfterWrite(Duration duration);
+
   /** Populate the cache with items from the CacheLoader. */
   CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
diff --git a/java/com/google/gerrit/server/cache/CacheDef.java b/java/com/google/gerrit/server/cache/CacheDef.java
index d0c633e..31a453e 100644
--- a/java/com/google/gerrit/server/cache/CacheDef.java
+++ b/java/com/google/gerrit/server/cache/CacheDef.java
@@ -51,6 +51,9 @@
   Duration expireFromMemoryAfterAccess();
 
   @Nullable
+  Duration refreshAfterWrite();
+
+  @Nullable
   Weigher<K, V> weigher();
 
   @Nullable
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index fe4244c..2dd9e1f 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -38,6 +38,7 @@
   private long maximumWeight;
   private Duration expireAfterWrite;
   private Duration expireFromMemoryAfterAccess;
+  private Duration refreshAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
 
@@ -90,6 +91,13 @@
   }
 
   @Override
+  public CacheBinding<K, V> refreshAfterWrite(Duration duration) {
+    checkNotFrozen();
+    refreshAfterWrite = duration;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
     checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
@@ -151,6 +159,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return refreshAfterWrite;
+  }
+
+  @Override
   @Nullable
   public Weigher<K, V> weigher() {
     return weigher != null ? weigher.get() : null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 5d9ce60..aa62745 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -43,6 +43,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return source.refreshAfterWrite();
+  }
+
+  @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
     if (weigher == null) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 8f7e360..82615a4 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -237,6 +237,7 @@
         def.valueSerializer(),
         def.version(),
         maxSize,
-        def.expireAfterWrite());
+        def.expireAfterWrite(),
+        def.expireFromMemoryAfterAccess());
   }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index ef4e44c..7a53600 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -23,6 +23,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -40,6 +43,7 @@
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.Calendar;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -122,7 +126,12 @@
   @Override
   public V get(K key) throws ExecutionException {
     if (mem instanceof LoadingCache) {
-      return ((LoadingCache<K, ValueHolder<V>>) mem).get(key).value;
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ValueHolder<V> valueHolder = asLoadingCache.get(key);
+      if (store.needsRefresh(valueHolder.created)) {
+        asLoadingCache.refresh(key);
+      }
+      return valueHolder.value;
     }
     throw new UnsupportedOperationException();
   }
@@ -139,8 +148,8 @@
                 }
               }
 
-              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
-              h.created = TimeUtil.nowMs();
+              ValueHolder<V> h =
+                  new ValueHolder<>(valueLoader.call(), Instant.ofEpochMilli(TimeUtil.nowMs()));
               executor.execute(() -> store.put(key, h));
               return h;
             })
@@ -149,8 +158,7 @@
 
   @Override
   public void put(K key, V val) {
-    final ValueHolder<V> h = new ValueHolder<>(val);
-    h.created = TimeUtil.nowMs();
+    final ValueHolder<V> h = new ValueHolder<>(val, Instant.ofEpochMilli(TimeUtil.nowMs()));
     mem.put(key, h);
     executor.execute(() -> store.put(key, h));
   }
@@ -217,11 +225,12 @@
 
   static class ValueHolder<V> {
     final V value;
-    long created;
+    final Instant created;
     volatile boolean clean;
 
-    ValueHolder(V value) {
+    ValueHolder(V value, Instant created) {
       this.value = value;
+      this.created = created;
     }
   }
 
@@ -248,12 +257,34 @@
           }
         }
 
-        final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
-        h.created = TimeUtil.nowMs();
+        final ValueHolder<V> h =
+            new ValueHolder<>(loader.load(key), Instant.ofEpochMilli(TimeUtil.nowMs()));
         executor.execute(() -> store.put(key, h));
         return h;
       }
     }
+
+    @Override
+    public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
+        throws Exception {
+      ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
+      Futures.addCallback(
+          reloadedValue,
+          new FutureCallback<V>() {
+            @Override
+            public void onSuccess(V result) {
+              store.put(key, new ValueHolder<>(result, TimeUtil.now()));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              logger.atWarning().withCause(t).log("Unable to reload cache value");
+            }
+          },
+          executor);
+
+      return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
+    }
   }
 
   static class SqlStore<K, V> {
@@ -263,6 +294,7 @@
     private final int version;
     private final long maxSize;
     @Nullable private final Duration expireAfterWrite;
+    @Nullable private final Duration refreshAfterWrite;
     private final BlockingQueue<SqlHandle> handles;
     private final AtomicLong hitCount = new AtomicLong();
     private final AtomicLong missCount = new AtomicLong();
@@ -276,13 +308,15 @@
         CacheSerializer<V> valueSerializer,
         int version,
         long maxSize,
-        @Nullable Duration expireAfterWrite) {
+        @Nullable Duration expireAfterWrite,
+        @Nullable Duration refreshAfterWrite) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
       this.version = version;
       this.maxSize = maxSize;
       this.expireAfterWrite = expireAfterWrite;
+      this.refreshAfterWrite = refreshAfterWrite;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -394,14 +428,14 @@
           }
 
           Timestamp created = r.getTimestamp(2);
-          if (expired(created)) {
+          if (expired(created.toInstant())) {
             invalidate(key);
             missCount.incrementAndGet();
             return null;
           }
 
           V val = valueSerializer.deserialize(r.getBytes(1));
-          ValueHolder<V> h = new ValueHolder<>(val);
+          ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
           touch(c, key);
@@ -429,14 +463,22 @@
       return false;
     }
 
-    private boolean expired(Timestamp created) {
+    private boolean expired(Instant created) {
       if (expireAfterWrite == null) {
         return false;
       }
-      Duration age = Duration.between(created.toInstant(), TimeUtil.now());
+      Duration age = Duration.between(created, TimeUtil.now());
       return age.compareTo(expireAfterWrite) > 0;
     }
 
+    private boolean needsRefresh(Instant created) {
+      if (refreshAfterWrite == null) {
+        return false;
+      }
+      Duration age = Duration.between(created, TimeUtil.now());
+      return age.compareTo(refreshAfterWrite) > 0;
+    }
+
     private void touch(SqlHandle c, K key) throws IOException, SQLException {
       if (c.touch == null) {
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
@@ -474,7 +516,7 @@
           keyType.set(c.put, 1, key);
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
-          c.put.setTimestamp(4, new Timestamp(holder.created));
+          c.put.setTimestamp(4, Timestamp.from(holder.created));
           c.put.setTimestamp(5, TimeUtil.nowTs());
           c.put.executeUpdate();
           holder.clean = true;
@@ -560,7 +602,7 @@
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
-              if (mem.getIfPresent(key) != null && !expired(created)) {
+              if (mem.getIfPresent(key) != null && !expired(created.toInstant())) {
                 touch(c, key);
               } else {
                 invalidate(c, key);
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 9906b3d..23caca7 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -105,6 +105,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.refreshAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
@@ -141,6 +156,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.expireAfterAccess(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index eb6e8d7..9e228d9 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -42,6 +43,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final String msgTxt;
   private final AccountState accountState;
@@ -61,12 +63,14 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
+      MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.accountState = accountState;
     this.msgTxt = Strings.nullToEmpty(msgTxt);
@@ -116,6 +120,7 @@
       }
       cm.setChangeMessage(message.getMessage(), ctx.getWhen());
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 2778bdd..ae3851e 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -37,13 +38,16 @@
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
       AddReviewerSender.Factory addReviewerSenderFactory,
-      @SendEmailExecutor ExecutorService sendEmailsExecutor) {
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
     this.sendEmailsExecutor = sendEmailsExecutor;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public void emailReviewersAsync(
@@ -86,6 +90,9 @@
                 cm.addReviewersByEmail(immutableAddedByEmail);
                 cm.addExtraCC(immutableToCopy);
                 cm.addExtraCCByEmail(immutableCopiedByEmail);
+                cm.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
                 cm.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index bbb94ea..467c4a2 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -108,6 +109,7 @@
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerAdder reviewerAdder;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -156,6 +158,7 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerAdder reviewerAdder,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -171,6 +174,7 @@
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerAdder = reviewerAdder;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -475,6 +479,8 @@
                 cm.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
                 cm.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                cm.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                 cm.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 3bc9324..e52375f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -36,6 +37,8 @@
   }
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final MessageIdGenerator messageIdGenerator;
+
   private final Address reviewer;
 
   private ChangeMessage changeMessage;
@@ -43,8 +46,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory, @Assisted Address reviewer) {
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewer;
   }
 
@@ -79,6 +85,8 @@
       cm.addReviewersByEmail(Collections.singleton(reviewer));
       cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
       cm.setNotify(notify);
+      cm.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
       cm.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 099334d..3d43f59 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -45,6 +46,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -69,6 +71,7 @@
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
@@ -90,6 +93,7 @@
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator,
       @Assisted AccountState reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
@@ -101,6 +105,7 @@
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -177,7 +182,7 @@
     }
     try {
       if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify);
+        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
       }
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
@@ -211,8 +216,9 @@
       Project.NameKey projectName,
       Change change,
       ChangeMessage changeMessage,
-      NotifyResolver.Result notify)
-      throws EmailException {
+      NotifyResolver.Result notify,
+      RepoView repoView)
+      throws EmailException, IOException {
     Account.Id userId = user.get().getAccountId();
     if (userId.equals(reviewer.account().id())) {
       // The user knows they removed themselves, don't bother emailing them.
@@ -223,6 +229,7 @@
     cm.addReviewers(Collections.singleton(reviewer.account().id()));
     cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
     cm.setNotify(notify);
+    cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
     cm.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f7e45e7..f1eff6f 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -65,13 +67,15 @@
         ChangeMessage message,
         List<Comment> comments,
         String patchSetComment,
-        List<LabelVote> labels);
+        List<LabelVote> labels,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final NotifyResolver.Result notify;
   private final ChangeNotes notes;
@@ -81,6 +85,7 @@
   private final List<Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
+  private final RepoView repoView;
 
   @Inject
   EmailReviewComments(
@@ -88,6 +93,7 @@
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
@@ -95,11 +101,13 @@
       @Assisted ChangeMessage message,
       @Assisted List<Comment> comments,
       @Nullable @Assisted String patchSetComment,
-      @Assisted List<LabelVote> labels) {
+      @Assisted List<LabelVote> labels,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
     this.requestContext = requestContext;
+    this.messageIdGenerator = messageIdGenerator;
     this.notify = notify;
     this.notes = notes;
     this.patchSet = patchSet;
@@ -108,6 +116,7 @@
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
+    this.repoView = repoView;
   }
 
   public void sendAsync() {
@@ -127,6 +136,7 @@
       cm.setPatchSetComment(patchSetComment);
       cm.setLabels(labels);
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, patchSet.id()));
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 988d178..d4d74a3 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -79,6 +80,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
+  private final MessageIdGenerator messageIdGenerator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -120,6 +122,7 @@
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -133,6 +136,7 @@
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -291,6 +295,7 @@
         cm.addReviewers(oldReviewers.byState(REVIEWER));
         cm.addExtraCC(oldReviewers.byState(CC));
         cm.setNotify(notify);
+        cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 9848150..74536aa 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -50,6 +51,7 @@
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private Change change;
   private IdentifiedUser oldAssignee;
@@ -62,6 +64,7 @@
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
@@ -69,6 +72,7 @@
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.newAssignee = requireNonNull(newAssignee, "assignee");
   }
 
@@ -122,6 +126,8 @@
           setAssigneeSenderFactory.create(
               change.getProject(), change.getId(), newAssignee.getAccountId());
       cm.setFrom(user.get().getAccountId());
+      cm.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
       cm.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 283cff8..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,8 +31,10 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -131,6 +134,13 @@
         || !sendEmail) {
       return;
     }
+    RepoView repoView;
+    try {
+      repoView = ctx.getRepoView();
+    } catch (IOException ex) {
+      throw new StorageException(
+          String.format("Repository %s not found", ctx.getProject().get()), ex);
+    }
     email
         .create(
             notify,
@@ -140,7 +150,8 @@
             cmsg,
             ImmutableList.of(),
             cmsg.getMessage(),
-            ImmutableList.of())
+            ImmutableList.of(),
+            repoView)
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index e7f4540..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.AbstractModule;
@@ -24,7 +28,7 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Module providing the {@link ReceiveCommitsExecutor}.
+ * Module providing different executors.
  *
  * <p>This module is intended to be installed at the top level when creating a {@code sysInjector}
  * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
@@ -37,7 +41,7 @@
   @Provides
   @Singleton
   @ReceiveCommitsExecutor
-  public ExecutorService createReceiveCommitsExecutor(
+  public ExecutorService provideReceiveCommitsExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize =
         config.getInt(
@@ -48,11 +52,11 @@
   @Provides
   @Singleton
   @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor(
+  public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "SendEmail", true);
   }
@@ -60,11 +64,24 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(@GerritServerConfig Config config, WorkQueue queues) {
+  public ExecutorService provideFanOutExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("execution", null, "fanOutThreadPoolSize", 25);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "FanOut");
   }
+
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService provideCacheRefreshExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("cache", null, "refreshThreadPoolSize", 2);
+    if (poolSize == 0) {
+      return newDirectExecutorService();
+    }
+    return MoreExecutors.listeningDecorator(queues.createQueue(poolSize, "CacheRefresh"));
+  }
 }
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 6b2510e..d3f90e5 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -47,6 +47,16 @@
     return getWebUrl().map(url -> url + "c/" + project.get() + "/+/" + id.get());
   }
 
+  /** Returns the URL for viewing the comment tab view of a change. */
+  default Optional<String> getCommentsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=comments");
+  }
+
+  /** Returns the URL for viewing the findings tab view of a change. */
+  default Optional<String> getFindingsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
+  }
+
   /** Returns the URL for viewing a file in a given patch set of a change. */
   default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
     return getChangeViewUrl(change.getProject(), change.getId())
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index df53133..329530c 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -86,6 +87,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   CommitUtil(
@@ -98,7 +100,8 @@
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
       ChangeReverted changeReverted,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      MessageIdGenerator messageIdGenerator) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.seq = seq;
@@ -109,6 +112,7 @@
     this.cmUtil = cmUtil;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -308,6 +312,8 @@
         RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setNotify(ctx.getNotify(change.getId()));
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index b272cba..89d6bd3 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -70,6 +71,7 @@
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final PatchSet.Id psId;
   private final SubmissionId submissionId;
@@ -90,6 +92,7 @@
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
@@ -101,6 +104,7 @@
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
+    this.messageIdGenerator = messageIdGenerator;
     this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
@@ -189,6 +193,8 @@
                           mergedSenderFactory.create(ctx.getProject(), psId.changeId());
                       cm.setFrom(ctx.getAccountId());
                       cm.setPatchSet(patchSet, info);
+                      cm.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                       cm.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 0baecf5..f5c69e0 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -128,6 +129,8 @@
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerAdder reviewerAdder;
+  private final Change change;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final ProjectState projectState;
   private final BranchNameKey dest;
@@ -140,7 +143,6 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
-  private final Change change;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -172,6 +174,7 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -197,6 +200,8 @@
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
+    this.change = change;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.projectState = projectState;
     this.dest = dest;
@@ -210,7 +215,6 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
-    this.change = change;
   }
 
   @Override
@@ -533,6 +537,7 @@
                     oldRecipients.getCcOnly().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
+        cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
         // TODO(dborowitz): Support byEmail
         cm.send();
       } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 3bb4770..0a99d83 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -31,116 +31,123 @@
 /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
 @AutoValue
 public abstract class Metadata {
-  // The numeric ID of an account.
+  /** The numeric ID of an account. */
   public abstract Optional<Integer> accountId();
 
-  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
-  // PLUGIN_UPDATE).
+  /**
+   * The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+   * PLUGIN_UPDATE).
+   */
   public abstract Optional<String> actionType();
 
-  // An authentication domain name.
+  /** An authentication domain name. */
   public abstract Optional<String> authDomainName();
 
-  // The name of a branch.
+  /** The name of a branch. */
   public abstract Optional<String> branchName();
 
-  // Key of an entity in a cache.
+  /** Key of an entity in a cache. */
   public abstract Optional<String> cacheKey();
 
-  // The name of a cache.
+  /** The name of a cache. */
   public abstract Optional<String> cacheName();
 
-  // The name of the implementation class.
+  /** The name of the implementation class. */
   public abstract Optional<String> className();
 
-  // The numeric ID of a change.
+  /** The numeric ID of a change. */
   public abstract Optional<Integer> changeId();
 
-  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  /**
+   * The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+   */
   public abstract Optional<String> changeIdType();
 
-  // The cause of an error.
+  /** The cause of an error. */
   public abstract Optional<String> cause();
 
-  // The type of an event.
+  /** The type of an event. */
   public abstract Optional<String> eventType();
 
-  // The value of the @Export annotation which was used to register a plugin extension.
+  /** The value of the @Export annotation which was used to register a plugin extension. */
   public abstract Optional<String> exportValue();
 
-  // Path of a file in a repository.
+  /** Path of a file in a repository. */
   public abstract Optional<String> filePath();
 
-  // Garbage collector name.
+  /** Garbage collector name. */
   public abstract Optional<String> garbageCollectorName();
 
-  // Git operation (CLONE, FETCH).
+  /** Git operation (CLONE, FETCH). */
   public abstract Optional<String> gitOperation();
 
-  // The numeric ID of an internal group.
+  /** The numeric ID of an internal group. */
   public abstract Optional<Integer> groupId();
 
-  // The name of a group.
+  /** The name of a group. */
   public abstract Optional<String> groupName();
 
-  // The UUID of a group.
+  /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
-  // HTTP status response code.
+  /** HTTP status response code. */
   public abstract Optional<Integer> httpStatus();
 
-  // The name of a secondary index.
+  /** The name of a secondary index. */
   public abstract Optional<String> indexName();
 
-  // The version of a secondary index.
+  /** The version of a secondary index. */
   public abstract Optional<Integer> indexVersion();
 
-  // The name of the implementation method.
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
-  // One or more resources
+  /** One or more resources */
   public abstract Optional<Boolean> multiple();
 
-  // The name of an operation that is performed.
+  /** The name of an operation that is performed. */
   public abstract Optional<String> operationName();
 
-  // Partial or full computation
+  /** Partial or full computation */
   public abstract Optional<Boolean> partial();
 
-  // Path of a metadata file in NoteDb.
+  /** If a value is still current or not */
+  public abstract Optional<Boolean> outdated();
+
+  /** Path of a metadata file in NoteDb. */
   public abstract Optional<String> noteDbFilePath();
 
-  // Name of a metadata ref in NoteDb.
+  /** Name of a metadata ref in NoteDb. */
   public abstract Optional<String> noteDbRefName();
 
-  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  /** Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS). */
   public abstract Optional<String> noteDbSequenceType();
 
-  // The ID of a patch set.
+  /** The ID of a patch set. */
   public abstract Optional<Integer> patchSetId();
 
-  // Plugin metadata that doesn't fit into any other category.
+  /** Plugin metadata that doesn't fit into any other category. */
   public abstract ImmutableList<PluginMetadata> pluginMetadata();
 
-  // The name of a plugin.
+  /** The name of a plugin. */
   public abstract Optional<String> pluginName();
 
-  // The name of a Gerrit project (aka Git repository).
+  /** The name of a Gerrit project (aka Git repository). */
   public abstract Optional<String> projectName();
 
-  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  /** The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE). */
   public abstract Optional<String> pushType();
 
-  // The number of resources that is processed.
+  /** The number of resources that is processed. */
   public abstract Optional<Integer> resourceCount();
 
-  // The name of a REST view.
+  /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
-  // The SHA1 of Git commit.
+  /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
-  // The username of an account.
+  /** The username of an account. */
   public abstract Optional<String> username();
 
   /**
@@ -305,6 +312,8 @@
 
     public abstract Builder partial(boolean partial);
 
+    public abstract Builder outdated(boolean outdated);
+
     public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
 
     public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 9c3dd02..f004e4b 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -115,6 +116,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   public MailProcessor(
@@ -133,7 +135,8 @@
       CommentAdded commentAdded,
       AccountCache accountCache,
       DynamicItem<UrlFormatter> urlFormatter,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
@@ -150,6 +153,7 @@
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.commentValidators = commentValidators;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   /**
@@ -222,6 +226,7 @@
     try {
       InboundEmailRejectionSender em =
           emailRejectionSender.create(message.from(), message.id(), reason);
+      em.setMessageId(messageIdGenerator.fromMailMessage(message));
       em.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
@@ -366,7 +371,8 @@
               changeMessage,
               comments,
               patchSetComment,
-              ImmutableList.of())
+              ImmutableList.of(),
+              ctx.getRepoView())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 3b7b2aa..e02f02a 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -35,11 +35,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -47,8 +52,12 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeys) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -58,6 +67,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 48d342e..f3cccf2 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -88,6 +88,16 @@
           .orElse(null);
     }
 
+    /** @return a web link to the comment tab view of a change. */
+    public String getCommentsTabLink() {
+      return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
+    }
+
+    /** @return a web link to the findings tab view of a change. */
+    public String getFindingsTabLink() {
+      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
+    }
+
     /**
      * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
      */
@@ -96,6 +106,8 @@
         return "Commit Message";
       } else if (Patch.MERGE_LIST.equals(filename)) {
         return "Merge List";
+      } else if (Patch.PATCHSET_LEVEL.equals(filename)) {
+        return "Patchset";
       } else {
         return "File " + filename;
       }
@@ -379,7 +391,9 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getFileLink());
+      if (!group.filename.equals(Patch.PATCHSET_LEVEL)) {
+        groupData.put("link", group.getFileLink());
+      }
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -407,7 +421,14 @@
         commentData.put("startLine", startLine);
 
         // Set the comment link.
-        if (comment.lineNbr == 0) {
+
+        if (comment.key.filename.equals(Patch.PATCHSET_LEVEL)) {
+          if (comment instanceof RobotComment) {
+            commentData.put("link", group.getFindingsTabLink());
+          } else {
+            commentData.put("link", group.getCommentsTabLink());
+          }
+        } else if (comment.lineNbr == 0) {
           commentData.put("link", group.getFileLink());
         } else {
           commentData.put("link", group.getCommentLink(comment.side, startLine));
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index 3df7f05..ce336ff 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -38,11 +38,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = Collections.emptyList();
     this.sshKey = sshKey;
@@ -51,9 +56,11 @@
   @AssistedInject
   public DeleteKeySender(
       EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeyFingerprints) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = gpgKeyFingerprints;
     this.sshKey = null;
@@ -63,6 +70,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index cec2bb5..5b3b34c 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -29,11 +30,16 @@
 
   private final IdentifiedUser user;
   private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public HttpPasswordUpdateSender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted String operation) {
     super(args, "HttpPasswordUpdate");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.operation = operation;
   }
@@ -42,6 +48,9 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP password change", user.getAccountId(), TimeUtil.nowTs()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
new file mode 100644
index 0000000..01e165d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -0,0 +1,126 @@
+// 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.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.sql.Timestamp;
+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;
+  }
+
+  /**
+   * 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 {
+    public abstract String id();
+  }
+
+  /**
+   * Create a {@link MessageId} as a result of a change update.
+   *
+   * @param repoView
+   * @param patchsetId
+   * @return MessageId that depends on the patchset.
+   */
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    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()))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  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());
+  }
+
+  /**
+   * @param accountId Create a {@link MessageId} as a result of an account update.
+   * @return 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());
+  }
+
+  /**
+   * Create a {@link MessageId} from a mail message.
+   *
+   * @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");
+  }
+
+  /**
+   * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
+   *
+   * @param reason for performing this account update
+   * @param accountId
+   * @param timestamp
+   * @return MessageId that depends on the reason, accountId, and timestamp.
+   */
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Timestamp 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);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 83c3a94..d81dca4 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -57,7 +57,6 @@
     super.init();
 
     String threadId = getChangeMessageThreadId();
-    setHeader("Message-ID", threadId);
     setHeader("References", threadId);
 
     switch (notify.handling()) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index b35bbec..fdd6b81 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -67,6 +67,7 @@
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
+  private MessageIdGenerator.MessageId messageId;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
@@ -88,6 +89,10 @@
     this.notify = requireNonNull(notify);
   }
 
+  public void setMessageId(MessageIdGenerator.MessageId messageId) {
+    this.messageId = messageId;
+  }
+
   /**
    * Format and enqueue the message for delivery.
    *
@@ -108,6 +113,9 @@
     }
 
     init();
+    if (messageId == null) {
+      throw new IllegalStateException("All emails must have a messageId");
+    }
     if (useHtml()) {
       appendHtml(soyHtmlTemplate("HeaderHtml"));
     }
@@ -201,31 +209,21 @@
         va.htmlBody = null;
       }
 
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          logger.atFine().log(
-              "Not sending '%s': Rejected by outgoing email validator: %s",
-              messageClass, e.getMessage());
-          return;
-        }
-      }
-
       Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly);
       if (!intersection.isEmpty()) {
         logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection);
       }
-
       if (!va.smtpRcptTo.isEmpty()) {
         // Send multipart message
+        addMessageId(va, "-HTML");
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending multipart '%s' from %s to %s",
             messageClass, va.smtpFromAddress, va.smtpRcptTo);
         args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
       }
-
       if (!smtpRcptToPlaintextOnly.isEmpty()) {
+        addMessageId(va, "-PLAIN");
         // Send plaintext message
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
@@ -238,6 +236,7 @@
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending plaintext '%s' from %s to %s",
             messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
@@ -246,6 +245,26 @@
     }
   }
 
+  private boolean validateEmail(OutgoingEmailValidationListener.Args va) {
+    for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+      try {
+        validator.validateOutgoingEmail(va);
+      } catch (ValidationException e) {
+        logger.atFine().log(
+            "Not sending '%s': Rejected by outgoing email validator: %s",
+            messageClass, e.getMessage());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) {
+    if (messageId != null) {
+      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(messageId.id() + suffix));
+    }
+  }
+
   /** Format the message body by calling {@link #appendText(String)}. */
   protected abstract void format() throws EmailException;
 
@@ -262,7 +281,6 @@
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
-    setHeader(FieldName.MESSAGE_ID, "");
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 128e185..1ead03c 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -27,6 +27,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -47,12 +48,16 @@
 
   private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
   private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-  private static final int CHUNK_SIZE = 100; // log progress after deleting every CHUNK_SIZE refs
+
+  // Number of refs deleted at once in a batch ref-update.
+  // Log progress after deleting every CHUNK_SIZE refs
+  private static final int CHUNK_SIZE = 3000;
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
   private Repository allUsersRepo;
+  private final Consumer<String> uiConsumer;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
@@ -63,9 +68,18 @@
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
       @Assisted Integer cleanupPercentage) {
+    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+  }
+
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      Consumer<String> uiConsumer) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.uiConsumer = uiConsumer;
   }
 
   public void execute() throws IOException {
@@ -74,15 +88,17 @@
     List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
     List<Ref> zombieRefs = filterZombieRefs(draftRefs);
 
-    logger.atInfo().log(
-        "Found a total of %d zombie draft refs in %s repo.", zombieRefs.size(), allUsers.get());
+    logInfo(
+        String.format(
+            "Found a total of %d zombie draft refs in %s repo.",
+            zombieRefs.size(), allUsers.get()));
 
-    logger.atInfo().log("Cleanup percentage = %d", cleanupPercentage);
+    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
     zombieRefs =
         zombieRefs.stream()
             .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
             .collect(toImmutableList());
-    logger.atInfo().log("Number of zombie refs to be cleaned = %d", zombieRefs.size());
+    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
 
     long zombieRefsCnt = zombieRefs.size();
     long deletedRefsCnt = 0;
@@ -124,8 +140,15 @@
     return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
   }
 
+  private void logInfo(String message) {
+    logger.atInfo().log(message);
+    uiConsumer.accept(message);
+  }
+
   private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
-    logger.atInfo().log(
-        "Deleted %d/%d zombie draft refs (%d seconds)\n", deletedRefsCount, allRefsCount, elapsed);
+    logInfo(
+        String.format(
+            "Deleted %d/%d zombie draft refs (%d seconds)",
+            deletedRefsCount, allRefsCount, elapsed));
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index eb6a280..b9e644f 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -31,7 +31,7 @@
   @Provides
   @Singleton
   @DiffExecutor
-  public ExecutorService createDiffExecutor() {
+  public ExecutorService provideDiffExecutor() {
     return new LoggingContextAwareExecutorService(
         Executors.newCachedThreadPool(
             new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
deleted file mode 100644
index eb451fd..0000000
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2012 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.project;
-
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import org.eclipse.jgit.lib.Config;
-
-/** Ticks periodically to force refresh events for {@link ProjectCacheImpl}. */
-@Singleton
-public class ProjectCacheClock implements LifecycleListener {
-  private final Config serverConfig;
-
-  private final AtomicLong generation = new AtomicLong();
-
-  private ScheduledExecutorService executor;
-
-  @Inject
-  public ProjectCacheClock(@GerritServerConfig Config serverConfig) {
-    this.serverConfig = serverConfig;
-  }
-
-  @Override
-  public void start() {
-    long checkFrequencyMillis = checkFrequency(serverConfig);
-
-    if (checkFrequencyMillis == Long.MAX_VALUE) {
-      // Start with generation 1 (to avoid magic 0 below).
-      // Do not begin background thread, disabling the clock.
-      generation.set(1);
-    } else if (10 < checkFrequencyMillis) {
-      // Start with generation 1 (to avoid magic 0 below).
-      generation.set(1);
-      executor =
-          new LoggingContextAwareScheduledExecutorService(
-              Executors.newScheduledThreadPool(
-                  1,
-                  new ThreadFactoryBuilder()
-                      .setNameFormat("ProjectCacheClock-%d")
-                      .setDaemon(true)
-                      .setPriority(Thread.MIN_PRIORITY)
-                      .build()));
-      @SuppressWarnings("unused") // Runnable already handles errors
-      Future<?> possiblyIgnoredError =
-          executor.scheduleAtFixedRate(
-              generation::incrementAndGet,
-              checkFrequencyMillis,
-              checkFrequencyMillis,
-              TimeUnit.MILLISECONDS);
-    } else {
-      // Magic generation 0 triggers ProjectState to always
-      // check on each needsRefresh() request we make to it.
-      generation.set(0);
-    }
-  }
-
-  @Override
-  public void stop() {
-    if (executor != null) {
-      executor.shutdown();
-    }
-  }
-
-  long read() {
-    return generation.get();
-  }
-
-  private static long checkFrequency(Config serverConfig) {
-    String freq = serverConfig.getString("cache", "projects", "checkFrequency");
-    if (freq != null && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
-      return Long.MAX_VALUE;
-    }
-    return TimeUnit.MILLISECONDS.convert(
-        ConfigUtil.getTimeUnit(
-            serverConfig, "cache", "projects", "checkFrequency", 5, TimeUnit.MINUTES),
-        TimeUnit.MINUTES);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 9d09eeb..a5c07e5 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,16 +24,23 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -48,6 +55,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -55,6 +63,7 @@
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /** Cache of project information, including access rights. */
@@ -70,7 +79,10 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
+        cache(CACHE_NAME, Project.NameKey.class, ProjectState.class)
+            .loader(Loader.class)
+            .refreshAfterWrite(Duration.ofMinutes(15))
+            .expireAfterWrite(Duration.ofHours(1));
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
             .maximumWeight(1)
@@ -84,7 +96,6 @@
               @Override
               protected void configure() {
                 listener().to(ProjectCacheWarmer.class);
-                listener().to(ProjectCacheClock.class);
               }
             });
       }
@@ -93,10 +104,9 @@
 
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
-  private final LoadingCache<String, ProjectState> byName;
+  private final LoadingCache<Project.NameKey, ProjectState> byName;
   private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
   private final Lock listLock;
-  private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
   private final Timer0 guessRelevantGroupsLatency;
 
@@ -104,9 +114,8 @@
   ProjectCacheImpl(
       final AllProjectsName allProjectsName,
       final AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
+      @Named(CACHE_NAME) LoadingCache<Project.NameKey, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker) {
     this.allProjectsName = allProjectsName;
@@ -114,7 +123,6 @@
     this.byName = byName;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
-    this.clock = clock;
     this.indexer = indexer;
 
     this.guessRelevantGroupsLatency =
@@ -142,13 +150,8 @@
     }
 
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return Optional.of(state);
-    } catch (Exception e) {
+      return Optional.of(byName.get(projectName));
+    } catch (ExecutionException e) {
       if ((e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atFine().log("Cannot find project %s", projectName.get());
         return Optional.empty();
@@ -167,7 +170,7 @@
   public void evict(Project.NameKey p) {
     if (p != null) {
       logger.atFine().log("Evict project '%s'", p.get());
-      byName.invalidate(p.get());
+      byName.invalidate(p);
     }
     indexer.get().index(p);
   }
@@ -222,7 +225,7 @@
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
       return all().stream()
-          .map(n -> byName.getIfPresent(n.get()))
+          .map(n -> byName.getIfPresent(n))
           .filter(Objects::nonNull)
           .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
           // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
@@ -245,41 +248,67 @@
     }
   }
 
-  static class Loader extends CacheLoader<String, ProjectState> {
+  @Singleton
+  static class Loader extends CacheLoader<Project.NameKey, ProjectState> {
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
-    private final ProjectCacheClock clock;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final ListeningExecutorService cacheRefreshExecutor;
+    private final Counter2<String, Boolean> refreshCounter;
 
     @Inject
     Loader(
         ProjectState.Factory psf,
         GitRepositoryManager g,
-        ProjectCacheClock clock,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        @CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
+        MetricMaker metricMaker) {
       projectStateFactory = psf;
       mgr = g;
-      this.clock = clock;
       this.projectConfigFactory = projectConfigFactory;
+      this.cacheRefreshExecutor = cacheRefreshExecutor;
+      refreshCounter =
+          metricMaker.newCounter(
+              "caches/refresh_count",
+              new Description("count").setRate(),
+              Field.ofString("cache", Metadata.Builder::className).build(),
+              Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
     }
 
     @Override
-    public ProjectState load(String projectName) throws Exception {
+    public ProjectState load(Project.NameKey key) throws Exception {
       try (TraceTimer timer =
           TraceContext.newTimer(
-              "Loading project", Metadata.builder().projectName(projectName).build())) {
-        long now = clock.read();
-        Project.NameKey key = Project.nameKey(projectName);
+              "Loading project", Metadata.builder().projectName(key.get()).build())) {
         try (Repository git = mgr.openRepository(key)) {
           ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
-
-          ProjectState state = projectStateFactory.create(cfg);
-          state.initLastCheck(now);
-          return state;
+          return projectStateFactory.create(cfg);
         }
       }
     }
+
+    @Override
+    public ListenableFuture<ProjectState> reload(Project.NameKey key, ProjectState oldState)
+        throws Exception {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Reload project", Metadata.builder().projectName(key.get()).build())) {
+        try (Repository git = mgr.openRepository(key)) {
+          Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+          if (configRef != null
+              && configRef.getObjectId().equals(oldState.getConfig().getRevision())) {
+            refreshCounter.increment(CACHE_NAME, false);
+            return Futures.immediateFuture(oldState);
+          }
+        }
+
+        // Repository is not thread safe, so we have to open it on the thread that does the loading.
+        // Just invoke the loader on the other thread.
+        refreshCounter.increment(CACHE_NAME, true);
+        return cacheRefreshExecutor.submit(() -> load(key));
+      }
+    }
   }
 
   static class ListKey {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index e52f344..efadcc8 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,7 +58,6 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -86,9 +84,6 @@
   private final long globalMaxObjectSizeLimit;
   private final boolean inheritProjectMaxObjectSizeLimit;
 
-  /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckGeneration;
-
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
@@ -140,33 +135,6 @@
     }
   }
 
-  void initLastCheck(long generation) {
-    lastCheckGeneration = generation;
-  }
-
-  boolean needsRefresh(long generation) {
-    if (generation <= 0) {
-      return isRevisionOutOfDate();
-    }
-    if (lastCheckGeneration != generation) {
-      lastCheckGeneration = generation;
-      return isRevisionOutOfDate();
-    }
-    return false;
-  }
-
-  private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
-      if (ref == null || ref.getObjectId() == null) {
-        return true;
-      }
-      return !ref.getObjectId().equals(config.getRevision());
-    } catch (IOException gone) {
-      return true;
-    }
-  }
-
   /**
    * @return cached computation of all global capabilities. This should only be invoked on the state
    *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index fee5eab..89b931f 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -80,6 +81,7 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
+  private final MessageIdGenerator messageIdGenerator;
   private final boolean isDevMode;
 
   @Inject
@@ -91,7 +93,8 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      MessageIdGenerator messageIdGenerator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -100,6 +103,7 @@
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -161,6 +165,7 @@
         if (!sender.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
+        sender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
         sender.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4c39763..bfe7177 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -77,6 +78,7 @@
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   DeleteVote(
@@ -89,7 +91,8 @@
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -100,6 +103,7 @@
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -229,6 +233,8 @@
           cm.setFrom(user.getAccountId());
           cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
           cm.setNotify(notify);
+          cm.setMessageId(
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
           cm.send();
         }
       } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 1e2e644..4ba1ab7 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -904,9 +905,23 @@
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
       if (notify.shouldNotify()) {
-        email
-            .create(notify, notes, ps, user, message, comments, in.message, labelDelta)
-            .sendAsync();
+        try {
+          email
+              .create(
+                  notify,
+                  notes,
+                  ps,
+                  user,
+                  message,
+                  comments,
+                  in.message,
+                  labelDelta,
+                  ctx.getRepoView())
+              .sendAsync();
+        } catch (IOException ex) {
+          throw new StorageException(
+              String.format("Repository %s not found", ctx.getProject().get()), ex);
+        }
       }
       commentAdded.fire(
           notes.getChange(),
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index a72192e..d1506b7 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -65,6 +66,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeRestored changeRestored;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   Restore(
@@ -74,7 +76,8 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeRestored changeRestored,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.restoredSenderFactory = restoredSenderFactory;
     this.json = json;
@@ -82,6 +85,7 @@
     this.psUtil = psUtil;
     this.changeRestored = changeRestored;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -149,6 +153,8 @@
         ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 88db66e..e2b898f 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -58,6 +58,7 @@
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
@@ -126,6 +127,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
+  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -154,7 +156,8 @@
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated) {
+      GetRelated getRelated,
+      MessageIdGenerator messageIdGenerator) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -175,6 +178,7 @@
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
+    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -601,6 +605,8 @@
         RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setNotify(ctx.getNotify(change.getId()));
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/schema/Schema_182.java b/java/com/google/gerrit/server/schema/Schema_182.java
index 3928b2b..a61a175 100644
--- a/java/com/google/gerrit/server/schema/Schema_182.java
+++ b/java/com/google/gerrit/server/schema/Schema_182.java
@@ -28,7 +28,8 @@
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     AllUsersName allUsers = args.allUsers;
     GitRepositoryManager gitRepoManager = args.repoManager;
-    DeleteZombieCommentsRefs cleanup = new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100);
+    DeleteZombieCommentsRefs cleanup =
+        new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100, ui::message);
     cleanup.execute();
   }
 }
diff --git a/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 0a6bcac..1159e06 100644
--- a/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -40,7 +40,7 @@
   @Provides
   @Singleton
   @SshListenAddresses
-  public List<SocketAddress> getListenAddresses(@GerritServerConfig Config cfg) {
+  public List<SocketAddress> provideListenAddresses(@GerritServerConfig Config cfg) {
     List<SocketAddress> listen = Lists.newArrayListWithExpectedSize(2);
     String[] want = cfg.getStringList("sshd", null, "listenaddress");
     if (want == null || want.length == 0) {
@@ -71,7 +71,7 @@
   @Provides
   @Singleton
   @SshAdvertisedAddresses
-  List<String> getAdvertisedAddresses(
+  List<String> provideAdvertisedAddresses(
       @GerritServerConfig Config cfg, @SshListenAddresses List<SocketAddress> listen) {
     String[] want = cfg.getStringList("sshd", null, "advertisedaddress");
     if (want.length > 0) {
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c94d49e..c433ee6 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
@@ -38,20 +40,23 @@
   interface Factory {
     EmailMerge create(
         Project.NameKey project,
-        Change.Id changeId,
+        Change change,
         Account.Id submitter,
-        NotifyResolver.Result notify);
+        NotifyResolver.Result notify,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
-  private final Change.Id changeId;
+  private final Change change;
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
+  private final RepoView repoView;
 
   @Inject
   EmailMerge(
@@ -59,18 +64,22 @@
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
+      @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyResolver.Result notify) {
+      @Assisted NotifyResolver.Result notify,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.project = project;
-    this.changeId = changeId;
+    this.change = change;
     this.submitter = submitter;
     this.notify = notify;
+    this.repoView = repoView;
   }
 
   void sendAsync() {
@@ -82,14 +91,15 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender cm = mergedSenderFactory.create(project, changeId);
+      MergedSender cm = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
         cm.setFrom(submitter);
       }
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
       cm.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", changeId);
+      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index a4141be..3bd052f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -500,7 +500,12 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
+          .create(
+              ctx.getProject(),
+              toMerge.change(),
+              submitter.accountId(),
+              ctx.getNotify(getId()),
+              ctx.getRepoView())
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 8800463..a682d33 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.testing;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
-import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
@@ -30,6 +31,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -209,7 +211,7 @@
           @Singleton
           @DiffExecutor
           public ExecutorService createDiffExecutor() {
-            return MoreExecutors.newDirectExecutorService();
+            return newDirectExecutorService();
           }
         });
     install(new DefaultMemoryCacheModule());
@@ -277,7 +279,7 @@
   @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
-    return MoreExecutors.newDirectExecutorService();
+    return newDirectExecutorService();
   }
 
   @Provides
@@ -287,6 +289,13 @@
     return queues.createQueue(2, "FanOut");
   }
 
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService createCacheRefreshExecutor() {
+    return newDirectExecutorService();
+  }
+
   private Module luceneIndexModule() {
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
new file mode 100644
index 0000000..7cb5377
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.testing.FakeEmailSender;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OutgoingEmailIT extends AbstractDaemonTest {
+
+  @Test
+  public void messageIdHeaderFromChangeUpdate() throws Exception {
+    Repository repository = repoManager.openRepository(project);
+    PushOneCommit.Result result = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).abandon();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            repository
+                    .getRefDatabase()
+                    .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                    .getObjectId()
+                    .getName()
+                + "-HTML");
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).restore();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            repository
+                    .getRefDatabase()
+                    .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                    .getObjectId()
+                    .getName()
+                + "-HTML");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "auth.registerEmailPrivateKey",
+      value = "HsOc6l_2lhS9G7sE_RsnS7Z6GJjdRDX14co=")
+  public void messageIdHeaderFromAccountUpdate() throws Exception {
+    Repository allUsersRepo = repoManager.openRepository(allUsers);
+    String email = "new.email@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    sender.clear();
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(Address.create(email));
+
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            allUsersRepo
+                    .getRefDatabase()
+                    .exactRef(RefNames.refsUsers(admin.id()))
+                    .getObjectId()
+                    .getName()
+                + "-HTML");
+  }
+
+  @Test
+  public void messageIdHeaderFromPasswordUpdate() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(getMessageId(sender)).contains("HTTP password change-" + admin.id().toString());
+  }
+
+  @Test
+  public void htmlAndPlainTextSuffixAddedToMessageId() throws Exception {
+    PushOneCommit.Result result = createChange();
+    GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    assertThat(getMessageId(sender)).contains("-PLAIN");
+    sender.clear();
+
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.reject());
+    assertThat(getMessageId(sender)).contains("-HTML");
+  }
+
+  private static String getMessageId(FakeEmailSender sender) {
+    return ((EmailHeader.String)
+            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+        .getString();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
new file mode 100644
index 0000000..3228900
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.sql.Timestamp;
+import java.time.Instant;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class MessageIdGeneratorIT extends AbstractDaemonTest {
+  @Inject private MessageIdGenerator messageIdGenerator;
+
+  @Test
+  public void fromAccountUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+      String sha1 =
+          repo.getRefDatabase().getRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromChangeUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result result = createChange();
+      PatchSet.Id patchsetId = result.getChange().currentPatchSet().id();
+      String messageId = messageIdGenerator.fromChangeUpdate(project, patchsetId).id();
+      String sha1 =
+          repo.getRefDatabase()
+              .getRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
+              .getObjectId()
+              .getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromMailMessage() throws Exception {
+    String id = "unique-id";
+    MailMessage mailMessage =
+        MailMessage.builder()
+            .id(id)
+            .from(Address.create("email@email.com"))
+            .dateReceived(Instant.EPOCH)
+            .subject("subject")
+            .build();
+    assertThat(messageIdGenerator.fromMailMessage(mailMessage).id()).isEqualTo(id + "-REJECTION");
+  }
+
+  @Test
+  public void fromReasonAccountIdAndTimestamp() throws Exception {
+    String reason = "reason";
+    Timestamp timestamp = TimeUtil.nowTs();
+    assertThat(
+            messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
+        .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 4fac821..e48c9d5 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -212,6 +212,20 @@
   }
 
   @Test
+  public void patchsetLevelCommentEmailNotification() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody).contains("Patchset");
+    assertThat(emailBody).doesNotContain("/PATCHSET_LEVEL");
+  }
+
+  @Test
   public void patchsetLevelCommentCantHaveLine() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index fc44822..4ae77da 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
@@ -296,6 +297,10 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
+
+    // ensure the message header contains a valid message id.
+    assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
+        .isEqualTo("some id-REJECTION-HTML");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 98f1b0e..438990c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,10 +6,12 @@
     deps = [
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
         "//lib/guice",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 69c2799..fb3a0db 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -16,16 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
+import java.time.Duration;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
 import org.junit.Test;
 
 public class H2CacheTest {
@@ -38,23 +49,31 @@
   }
 
   private static H2CacheImpl<String, String> newH2CacheImpl(
-      int id, Cache<String, ValueHolder<String>> mem, int version) {
-    SqlStore<String, String> store =
-        new SqlStore<>(
-            "jdbc:h2:mem:Test_" + id,
-            KEY_TYPE,
-            StringCacheSerializer.INSTANCE,
-            StringCacheSerializer.INSTANCE,
-            version,
-            1 << 20,
-            null);
+      SqlStore<String, String> store, Cache<String, ValueHolder<String>> mem) {
     return new H2CacheImpl<>(MoreExecutors.directExecutor(), store, KEY_TYPE, mem);
   }
 
+  private static SqlStore<String, String> newStore(
+      int id,
+      int version,
+      @Nullable Duration expireAfterWrite,
+      @Nullable Duration refreshAfterWrite) {
+    return new SqlStore<>(
+        "jdbc:h2:mem:Test_" + id,
+        KEY_TYPE,
+        StringCacheSerializer.INSTANCE,
+        StringCacheSerializer.INSTANCE,
+        version,
+        1 << 20,
+        expireAfterWrite,
+        refreshAfterWrite);
+  }
+
   @Test
   public void get() throws ExecutionException {
     Cache<String, ValueHolder<String>> mem = CacheBuilder.newBuilder().build();
-    H2CacheImpl<String, String> impl = newH2CacheImpl(nextDbId(), mem, DEFAULT_VERSION);
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
 
     assertThat(impl.getIfPresent("foo")).isNull();
 
@@ -94,11 +113,12 @@
   }
 
   @Test
-  public void version() throws Exception {
+  public void version() {
     int id = nextDbId();
-    H2CacheImpl<String, String> oldImpl = newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION);
+    H2CacheImpl<String, String> oldImpl =
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION, null, null), disableMemCache());
     H2CacheImpl<String, String> newImpl =
-        newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION + 1);
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION + 1, null, null), disableMemCache());
 
     assertThat(oldImpl.diskStats().space()).isEqualTo(0);
     assertThat(newImpl.diskStats().space()).isEqualTo(0);
@@ -124,6 +144,57 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
+  @Test
+  public void refreshAfterWrite_triggeredWhenConfigured() throws Exception {
+    SqlStore<String, String> store =
+        newStore(nextDbId(), DEFAULT_VERSION, null, Duration.ofMillis(10));
+
+    // This is the loader that we configure for the cache when calling .loader(...)
+    @SuppressWarnings("unchecked")
+    CacheLoader<String, String> baseLoader = (CacheLoader<String, String>) mock(CacheLoader.class);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // We wrap baseLoader just like H2CacheFactory is wrapping it. The wrapped version will call out
+    // to the store for refreshing values.
+    H2CacheImpl.Loader<String, String> wrappedLoader =
+        new H2CacheImpl.Loader<>(MoreExecutors.directExecutor(), store, baseLoader);
+    // memCache is the in-memory variant of the cache. Its loader is wrappedLoader which will call
+    // out to the store to save or delete cached values.
+    LoadingCache<String, ValueHolder<String>> memCache =
+        CacheBuilder.newBuilder().maximumSize(10).build(wrappedLoader);
+
+    // h2Cache puts it all together
+    H2CacheImpl<String, String> h2Cache = newH2CacheImpl(store, memCache);
+
+    // Initial load and cache retrieval do not trigger refresh
+    // This works because we use a directExecutor() for refreshes
+    TimeUtil.setCurrentMillisSupplier(() -> 0);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verify(baseLoader).load("foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Load after refresh duration returns old value, triggers refresh and returns new value
+    TimeUtil.setCurrentMillisSupplier(() -> 11);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("reload:foo");
+    verify(baseLoader).reload("foo", "load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Refreshed value was persisted
+    memCache.invalidateAll(); // Invalidates only the memcache, not the store.
+    assertThat(h2Cache.getIfPresent("foo")).isEqualTo("reload:foo");
+  }
+
+  private static void resetLoaderAndAnswerLoadAndRefreshCalls(CacheLoader<String, String> loader)
+      throws Exception {
+    reset(loader);
+    when(loader.load("foo")).thenReturn("load:foo");
+    when(loader.reload("foo", "load:foo")).thenReturn(Futures.immediateFuture("reload:foo"));
+  }
+
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/plugins/replication b/plugins/replication
index 9ae2087..3854fe6 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 9ae20871646a0757979e3bef03aaf4e74af8ff73
+Subproject commit 3854fe670a93235840be4886415203f6ee22c5ad
diff --git a/polygerrit-ui/app/constants/constants.js b/polygerrit-ui/app/constants/constants.js
index 74ebebd..ff34442 100644
--- a/polygerrit-ui/app/constants/constants.js
+++ b/polygerrit-ui/app/constants/constants.js
@@ -21,6 +21,9 @@
  */
 export const PrimaryTab = {
   FILES: 'files',
+  /**
+   * When renaming this, the links in UrlFormatter must be updated.
+   */
   COMMENT_THREADS: 'comments',
   FINDINGS: 'findings',
 };
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
index 4add1da..b322f38 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
@@ -86,14 +86,15 @@
         href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
         class$="[[_computePrevArrowClass(_offset)]]"
       >
-        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
       </a>
       <a
         id="nextArrow"
         href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
         class$="[[_computeNextArrowClass(_changes)]]"
       >
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </iron-icon>
       </a>
     </nav>
   </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
index 916a56f..200ffc9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
@@ -137,7 +137,6 @@
     <gr-dropdown
       id="moreActions"
       link=""
-      tabindex="0"
       vertical-offset="32"
       horizontal-align="right"
       on-tap-item="_handleOverflowItemTap"
@@ -145,7 +144,8 @@
       disabled-ids="[[_disabledMenuActions]]"
       items="[[_menuActions]]"
     >
-      <iron-icon icon="gr-icons:more-vert"></iron-icon>
+      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+      </iron-icon>
       <span id="moreMessage">More</span>
     </gr-dropdown>
   </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 12b5aad..d09c942 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -225,6 +225,11 @@
         computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
                 '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
       },
+      _showPrependedDynamicColumns: {
+        type: Boolean,
+        computed: '_computeShowPrependedDynamicColumns(' +
+        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+      },
       /** @type {Array<string>} */
       _dynamicHeaderEndpoints: {
         type: Array,
@@ -237,6 +242,14 @@
       _dynamicSummaryEndpoints: {
         type: Array,
       },
+      /** @type {Array<string>} */
+      _dynamicPrependedHeaderEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicPrependedContentEndpoints: {
+        type: Array,
+      },
     };
   }
 
@@ -295,18 +308,27 @@
   attached() {
     super.attached();
     pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-header');
-      this._dynamicContentEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-content');
-      this._dynamicSummaryEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-summary');
+      this._dynamicHeaderEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-header');
+      this._dynamicContentEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-content');
+      this._dynamicPrependedHeaderEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-header-prepend');
+      this._dynamicPrependedContentEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-content-prepend');
+      this._dynamicSummaryEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-summary');
 
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length) {
         console.warn(
             'Different number of dynamic file-list header and content.');
       }
+      if (this._dynamicPrependedHeaderEndpoints.length !==
+        this._dynamicPrependedContentEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list header and content.');
+      }
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length) {
         console.warn(
@@ -1432,11 +1454,23 @@
   _computeShowDynamicColumns(
       headerEndpoints, contentEndpoints, summaryEndpoints) {
     return headerEndpoints && contentEndpoints && summaryEndpoints &&
+           headerEndpoints.length &&
            headerEndpoints.length === contentEndpoints.length &&
            headerEndpoints.length === summaryEndpoints.length;
   }
 
   /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+      headerEndpoints, contentEndpoints) {
+    return headerEndpoints && contentEndpoints &&
+           headerEndpoints.length &&
+           headerEndpoints.length === contentEndpoints.length;
+  }
+
+  /**
    * Returns true if none of the inline diffs have been expanded.
    *
    * @return {boolean}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
index dab5f3a..ccf7f1a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -297,10 +297,22 @@
   </style>
   <div id="container" on-click="_handleFileListClick">
     <div class="header-row row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicPrependedHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]">
+          </gr-endpoint-decorator>
+        </template>
+      </template>
       <div class="path">File</div>
       <div class="comments">Comments</div>
       <div class="sizeBars">Size</div>
       <div class="header-stats">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
       <template is="dom-if" if="[[_showDynamicColumns]]">
         <template
           is="dom-repeat"
@@ -332,6 +344,23 @@
           data-file$="[[_computeFileRange(file)]]"
           tabindex="-1"
         >
+          <!-- endpoint: change-view-file-list-content-prepend -->
+          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicPrependedContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <gr-endpoint-decorator name="[[contentEndpoint]]">
+                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="path" value="[[file.__path]]">
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </template>
+          </template>
           <!-- TODO: Remove data-url as it appears its not used -->
           <span
             data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
@@ -433,6 +462,7 @@
               file.size_delta)]]
             </span>
           </div>
+          <!-- endpoint: change-view-file-list-content -->
           <template is="dom-if" if="[[_showDynamicColumns]]">
             <template
               is="dom-repeat"
@@ -537,6 +567,7 @@
         -[[_patchChange.deleted]]
       </span>
     </div>
+    <!-- endpoint: change-view-file-list-summary -->
     <template is="dom-if" if="[[_showDynamicColumns]]">
       <template
         is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index 132cd16..c42c734 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -74,7 +74,9 @@
   }
 
   _computeGroups(includedIn, filterText) {
-    if (!includedIn) { return []; }
+    if (!includedIn || filterText === undefined) {
+      return [];
+    }
 
     const filter = item => !filterText.length ||
         item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
@@ -105,12 +107,6 @@
   _computeLoadingClass(loaded) {
     return loaded ? 'loading loaded' : 'loading';
   }
-
-  _onFilterChanged() {
-    this.debounce('filter-change', () => {
-      this._filterText = this.$.filterInput.bindValue;
-    }, 100);
-  }
 }
 
 customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
index f5948e8..2faac52 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
@@ -73,12 +73,15 @@
         >Close</gr-button
       >
     </span>
-    <iron-input placeholder="Filter" on-bind-value-changed="_onFilterChanged">
+    <iron-input
+      id="filterInput"
+      placeholder="Filter"
+      bind-value="{{_filterText}}"
+    >
       <input
-        id="filterInput"
         is="iron-input"
         placeholder="Filter"
-        on-bind-value-changed="_onFilterChanged"
+        bind-value="{{_filterText}}"
       />
     </iron-input>
   </header>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index fe6119e..5d5b1fc 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -81,5 +81,20 @@
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
   });
+
+  test('_computeGroups with .bindValue', done => {
+    element.$.filterInput.bindValue = 'stable-3.2';
+    const includedIn = {branches: [], tags: []};
+    includedIn.branches.push('master', 'stable-3.2');
+
+    setTimeout(() => {
+      const filterText = element._filterText;
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['stable-3.2']},
+      ]);
+
+      done();
+    });
+  });
 });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 3dda25b..8c9d2de 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -74,14 +74,6 @@
         computed: '_computeAuthor(message)',
       },
       /**
-       * Used in cleaner_changelog experiment
-       *
-       * @type {Array} - array of threads attached to the message
-       */
-      commentThreads: {
-        type: Array,
-      },
-      /**
        * TODO(taoalpha): remove once the change log experiment is launched
        *
        * @type {Object} - a map on file and comments on it
@@ -140,11 +132,11 @@
         type: String,
         computed:
             '_computeMessageContentCollapsed(message.message, message.tag,' +
-            ' commentThreads)',
+            ' message.commentThreads)',
       },
       _commentCountText: {
         type: Number,
-        computed: '_computeCommentCountText(comments, commentThreads,'
+        computed: '_computeCommentCountText(comments, message.commentThreads,'
             + ' _isCleanerLogExperimentEnabled)',
       },
       _loggedIn: {
@@ -273,7 +265,7 @@
   _computeMessageContentCollapsed(content, tag, commentThreads) {
     const summary =
       this._computeMessageContent(content, tag, false);
-    if (summary || !commentThreads) return;
+    if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
index 6425232..25d0f65 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
@@ -258,8 +258,8 @@
             <template is="dom-if" if="[[_isCleanerLogExperimentEnabled]]">
               <gr-thread-list
                 change="[[change]]"
-                hidden$="[[!commentThreads.length]]"
-                threads="[[commentThreads]]"
+                hidden$="[[!message.commentThreads.length]]"
+                threads="[[message.commentThreads]]"
                 change-num="[[changeNum]]"
                 logged-in="[[_loggedIn]]"
                 hide-toggle-buttons
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index edcb7f6..332ca52 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -252,6 +252,8 @@
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet';
         let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
         assert.equal(actual, original);
         actual = element._computeMessageContent(original, tag, false);
         assert.equal(actual, original);
@@ -263,6 +265,8 @@
         const expected = 'Patch Set 26 was rebased';
         let actual = element._computeMessageContent(original, tag, true);
         assert.equal(actual, expected);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
         actual = element._computeMessageContent(original, tag, false);
         assert.equal(actual, expected);
       });
@@ -273,6 +277,8 @@
         const expected = 'This change is ready for review.';
         let actual = element._computeMessageContent(original, tag, true);
         assert.equal(actual, expected);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
         actual = element._computeMessageContent(original, tag, false);
         assert.equal(actual, expected);
       });
@@ -414,8 +420,9 @@
         path: '/PATCHSET_LEVEL',
         rootId: 'e365b138_bed65caa',
       }];
-      assert.equal(element._patchsetCommentSummary(threads),
-          'testing the load');
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'testing the load');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
     });
 
     test('single patchset comment with reply', () => {
@@ -446,7 +453,9 @@
         path: '/PATCHSET_LEVEL',
         rootId: 'e365b138_bed65caa',
       }];
-      assert.equal(element._patchsetCommentSummary(threads), 'n');
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'n');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
index ccc4a76..9037152 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
@@ -275,8 +275,9 @@
    * all messages and updates, aligns or massages some of the properties.
    */
   _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
-    messages = messages || [];
-    reviewerUpdates = reviewerUpdates || [];
+    const params = [messages, reviewerUpdates, changeComments];
+    if (params.some(o => o === undefined)) return [];
+
     let mi = 0;
     let ri = 0;
     let combinedMessages = [];
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
index 83edab3..5fd64d2 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
@@ -72,8 +72,10 @@
         <paper-toggle-button
           class="showAllActivityToggle"
           checked="{{_showAllActivity}}"
+          aria-labelledby="showAllEntriesLabel"
+          role="switch"
         ></paper-toggle-button>
-        <div>
+        <div id="showAllEntriesLabel">
           <span>Show all entries</span>
           <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
             ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
@@ -111,7 +113,6 @@
     <gr-message
       change-num="[[changeNum]]"
       message="[[message]]"
-      comment-threads="[[message.commentThreads]]"
       project-name="[[projectName]]"
       show-reply-button="[[showReplyButtons]]"
       on-message-anchor-tap="_handleAnchorClick"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
index 71f25b7..65229e1 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
@@ -302,11 +302,11 @@
       const messageElements = getMessages();
       // threads
       assert.equal(
-          messageElements[0].commentThreads.length,
+          messageElements[0].message.commentThreads.length,
           3);
       // first thread contains 1 comment
       assert.equal(
-          messageElements[0].commentThreads[0].comments.length,
+          messageElements[0].message.commentThreads[0].comments.length,
           1);
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
index ddae150..5adfc53 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
@@ -79,8 +79,10 @@
       <paper-toggle-button
         id="automatedMessageToggle"
         checked="{{_hideAutomated}}"
+        aria-labelledby="onlyCommentsLabel"
+        role="switch"
       ></paper-toggle-button
-      >Only comments
+      ><span id="onlyCommentsLabel">Only comments</span>
       <span class="transparent separator"></span>
     </span>
     <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
index 721e7510..162a7c0 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
@@ -57,20 +57,16 @@
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
       <div class="toggleItem">
-        <paper-toggle-button
-          id="unresolvedToggle"
-          checked="{{_unresolvedOnly}}"
-        ></paper-toggle-button>
-        Only unresolved threads
+        <paper-toggle-button id="unresolvedToggle" checked="{{_unresolvedOnly}}"
+          >Only unresolved threads</paper-toggle-button
+        >
       </div>
       <div
         class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
       >
-        <paper-toggle-button
-          id="draftToggle"
-          checked="{{_draftsOnly}}"
-        ></paper-toggle-button>
-        Only threads with drafts
+        <paper-toggle-button id="draftToggle" checked="{{_draftsOnly}}"
+          >Only threads with drafts</paper-toggle-button
+        >
       </div>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
index 1d4b440..cc11cfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
@@ -51,7 +51,7 @@
       }
     }
   </style>
-  <span class="patchRange">
+  <span class="patchRange" aria-label="patch range starts with">
     <gr-dropdown-list
       id="basePatchDropdown"
       value="[[basePatchNum]]"
@@ -67,8 +67,8 @@
       >
     </template>
   </span>
-  <span class="arrow">→</span>
-  <span class="patchRange">
+  <span aria-hidden="true" class="arrow">→</span>
+  <span class="patchRange" aria-label="patch range ends with">
     <gr-dropdown-list
       id="patchNumDropdown"
       value="[[patchNum]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
index 96e0160..ee19374 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
@@ -92,7 +92,6 @@
       link=""
       hidden$="[[!removable]]"
       hidden=""
-      tabindex="-1"
       aria-label="Remove"
       class$="remove [[_getBackgroundClass(transparentBackground)]]"
       on-click="_handleRemoveTap"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
index 17e7f49..ce2dc9e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
@@ -40,7 +40,7 @@
     }
   </style>
   <span>
-    <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
+    <a href$="[[_computeOwnerLink(account)]]">
       <gr-account-label
         show-attention="[[showAttention]]"
         hide-avatar="[[hideAvatar]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 6ef4807..bdac50f 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -65,6 +65,11 @@
         value: false,
         reflectToAttribute: true,
       },
+      ariaDisabled: {
+        type: Boolean,
+        computed: '_computeDisabled(disabled, loading)',
+        reflectToAttribute: true,
+      },
 
       _disabled: {
         type: Boolean,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 153b98d..6697880 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -347,6 +347,10 @@
     return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
   }
 
+  _computeShowHideAriaLabel(collapsed) {
+    return collapsed ? 'Expand' : 'Collapse';
+  }
+
   _calculateActionstoShow(showActions, isRobotComment) {
     // Polymer 2: check for undefined
     if ([showActions, isRobotComment].some(arg => arg === undefined)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
index 3d27042..b83cdf2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
@@ -277,7 +277,10 @@
         ></gr-date-formatter>
       </span>
       <div class="show-hide">
-        <label class="show-hide">
+        <label
+          class="show-hide"
+          aria-label="[[_computeShowHideAriaLabel(collapsed)]]"
+        >
           <input
             type="checkbox"
             class="show-hide"
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
new file mode 100644
index 0000000..2e37c01
--- /dev/null
+++ b/polygerrit-ui/app/samples/extra-column-on-file-list.js
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * 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.
+ */
+/**
+ * This plugin will an extra column to file list on change page to show
+ * the first character of the path.
+ */
+
+// Header of this extra column
+class ColumnHeader extends Polymer.Element {
+  static get is() { return 'column-header'; }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display: block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>First Char</div>
+    `;
+  }
+}
+
+customElements.define(ColumnHeader.is, ColumnHeader);
+
+// Content of this extra column
+class ColumnContent extends Polymer.Element {
+  static get is() { return 'column-content'; }
+
+  static get properties() {
+    return {
+      path: String,
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display:block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>[[getStatus(path)]]</div>
+    `;
+  }
+
+  getStatus(path) {
+    return path.charAt(0);
+  }
+}
+
+customElements.define(ColumnContent.is, ColumnContent);
+
+Gerrit.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-header-prepend', ColumnHeader.is);
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-content-prepend', ColumnContent.is);
+});
\ No newline at end of file
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 120aff5..c44493d 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -61,6 +61,7 @@
 
 	dirListingMux := http.NewServeMux()
 	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
+	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
 	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
 	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
 
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index a0f5bd1..7febbac 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -3,3 +3,8 @@
     "test_empty.sh",
     "test_license.sh",
 ])
+
+sh_test(
+    name = "always_pass_test",
+    srcs = ["always_pass_test.sh"],
+)
diff --git a/tools/bzl/always_pass_test.sh b/tools/bzl/always_pass_test.sh
new file mode 100755
index 0000000..15c58ca
--- /dev/null
+++ b/tools/bzl/always_pass_test.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+#
+# 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.
+
+# This is a dummy test to put on the command line to avoid no tests
+# found outcome in `bazel test` command. See this upstream issue:
+# https://github.com/bazelbuild/bazel/issues/11465
+
+exit 0
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index eced448..c37ce12 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,8 +23,8 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.1.8",
-        sha1 = "f4765fc53af5d5712261a7e29afe97beb6b30118",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.9",
+        sha1 = "dd76a62b007ffea9e6aba10f64c04173ef65f895",
     )
 
     SSHD_VERS = "2.4.0"