Merge "Merge branch 'stable-3.2'"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 134af9a..f02d89a 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -182,20 +182,6 @@
 +
 By default 1.
 
-[[asyncPostUpdate]]
-=== Section asyncPostUpdate
-
-[[asyncPostUpdate.threadPoolSize]]asyncPostUpdate.threadPoolSize::
-+
-Maximum size of thread pool in which async post updates are sent out.
-+
-When set to 0, a direct executor is used.
-+
-When unset, use link:#sendemail[sendemail.threadPoolSize] threadPoolSize. When both
-are unset, use the default.
-+
-By default, 1.
-
 [[auth]]
 === Section auth
 
@@ -884,8 +870,6 @@
 The check if they are is cheap and always happens on the thread that
 inquires for a cached value.
 +
-When set to 0, a direct executor is used.
-+
 Defaults to 2.
 
 ==== [[cache_names]]Standard Caches
@@ -4490,7 +4474,11 @@
 an error occurs.
 
 [[sendemail.threadPoolSize]]sendemail.threadPoolSize::
-Deprecated. Replaced with link:#asyncPostUpdate.threadPoolSize[asyncPostUpdate.threadPoolSize]
++
+Maximum size of thread pool in which the review comments
+notifications are sent out asynchronously.
++
+By default, 1.
 
 [[sendemail.from]]sendemail.from::
 +
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 32bc992..452df67 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -48,7 +48,6 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
 import org.eclipse.jgit.junit.TestRepository;
@@ -98,7 +97,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
-    private Optional<Message> message;
+    private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
     private Set<String> accountedFor = new HashSet<>();
@@ -117,29 +116,35 @@
     }
 
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      fakeEmailSender.readOneMessage();
-      message =
-          fakeEmailSender.getMessages().stream()
-              .filter(
-                  m ->
-                      m.headers()
-                          .get("X-Gerrit-MessageType")
-                          .equals(new EmailHeader.String(messageType)))
-              .findFirst();
-      if (!message.isPresent()) {
-        failWithoutActual(
-            fact(String.format("expected message of type %s", messageType), "not sent"));
+      message = fakeEmailSender.nextMessage();
+      if (message == null) {
+        failWithoutActual(fact("expected message", "not sent"));
       }
       recipients = new HashMap<>();
-      recipients.put(TO, parseAddresses(message.get(), "To"));
-      recipients.put(CC, parseAddresses(message.get(), "Cc"));
+      recipients.put(TO, parseAddresses(message, "To"));
+      recipients.put(CC, parseAddresses(message, "Cc"));
       recipients.put(
           BCC,
-          message.get().rcpt().stream()
+          message.rcpt().stream()
               .map(Address::email)
               .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
               .collect(toList()));
       this.users = users;
+      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
+        failWithoutActual(
+            fact("expected to have message sent with", "X-Gerrit-MessageType header"));
+      }
+      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
+      if (!header.equals(new EmailHeader.String(messageType))) {
+        failWithoutActual(
+            fact("expected message of type", messageType),
+            fact(
+                "actual",
+                header instanceof EmailHeader.String
+                    ? ((EmailHeader.String) header).getString()
+                    : header));
+      }
+
       return this;
     }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 465c419..b8c841c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
-
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment.Status;
 import com.google.gerrit.entities.HumanComment;
@@ -32,8 +30,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -58,7 +54,6 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final CommentsUtil commentsUtil;
-  private final PatchListCache patchListCache;
 
   private final ChangeNotes changeNotes;
   private final PatchSet.Id patchsetId;
@@ -73,14 +68,12 @@
       GenericFactory userFactory,
       BatchUpdate.Factory batchUpdateFactory,
       CommentsUtil commentsUtil,
-      PatchListCache patchListCache,
       @Assisted ChangeNotes changeNotes,
       @Assisted PatchSet.Id patchsetId) {
     this.repositoryManager = repositoryManager;
     this.userFactory = userFactory;
     this.batchUpdateFactory = batchUpdateFactory;
     this.commentsUtil = commentsUtil;
-    this.patchListCache = patchListCache;
     this.changeNotes = changeNotes;
     this.patchsetId = patchsetId;
   }
@@ -154,7 +147,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext context) throws Exception {
+    public boolean updateChange(ChangeContext context) {
       HumanComment comment = toNewComment(context, commentCreation);
       ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
       changeUpdate.putComment(commentCreation.status(), comment);
@@ -165,8 +158,7 @@
       return true;
     }
 
-    private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation)
-        throws PatchListNotAvailableException {
+    private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation) {
       String message = commentCreation.message().orElse("The text of a test comment.");
 
       String filePath = commentCreation.file().orElse(Patch.PATCHSET_LEVEL);
@@ -197,11 +189,8 @@
           .map(PerPatchsetOperationsImpl::toCommentRange)
           .ifPresent(range -> newComment.setLineNbrAndRange(null, range));
 
-      setCommentCommitId(
-          newComment,
-          patchListCache,
-          context.getChange(),
-          changeNotes.getPatchSets().get(patchsetId));
+      commentsUtil.setCommentCommitId(
+          newComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
       return newComment;
     }
   }
@@ -236,7 +225,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext context) throws Exception {
+    public boolean updateChange(ChangeContext context) {
       RobotComment robotComment = toNewRobotComment(context, robotCommentCreation);
       ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
       changeUpdate.putRobotComment(robotComment);
@@ -248,8 +237,7 @@
     }
 
     private RobotComment toNewRobotComment(
-        ChangeContext context, TestRobotCommentCreation robotCommentCreation)
-        throws PatchListNotAvailableException {
+        ChangeContext context, TestRobotCommentCreation robotCommentCreation) {
       String message = robotCommentCreation.message().orElse("The text of a test robot comment.");
 
       String filePath = robotCommentCreation.file().orElse(Patch.PATCHSET_LEVEL);
@@ -281,11 +269,8 @@
         newRobotComment.properties = robotCommentCreation.properties();
       }
 
-      setCommentCommitId(
-          newRobotComment,
-          patchListCache,
-          context.getChange(),
-          changeNotes.getPatchSets().get(patchsetId));
+      commentsUtil.setCommentCommitId(
+          newRobotComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
       return newRobotComment;
     }
   }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 4d5778a..b752791 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
@@ -55,6 +56,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /** Utility functions to manipulate Comments. */
 @Singleton
@@ -109,13 +111,18 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final String serverId;
+  private final PatchListCache patchListCache;
 
   @Inject
   CommentsUtil(
-      GitRepositoryManager repoManager, AllUsersName allUsers, @GerritServerId String serverId) {
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      @GerritServerId String serverId,
+      PatchListCache patchListCache) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.serverId = serverId;
+    this.patchListCache = patchListCache;
   }
 
   public HumanComment newHumanComment(
@@ -372,41 +379,63 @@
     return sort(result);
   }
 
-  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
-      throws PatchListNotAvailableException {
+  public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
     checkArgument(
         c.key.patchSetId == ps.id().get(),
         "cannot set commit ID for patch set %s on comment %s",
         ps.id(),
         c);
     if (c.getCommitId() == null) {
-      c.setCommitId(determineCommitId(cache, change, ps, c.side));
+      // This code is very much down into our stack and shouldn't be used for validation. Hence,
+      // don't throw an exception here if we can't find a commit for the indicated side but
+      // simply use the all-null ObjectId.
+      c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
     }
   }
 
   /**
    * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
    *
-   * @param patchListCache the cache to use for SHA-1 lookups
    * @param change the change to which the commit belongs
    * @param patchset the patchset to which the commit belongs
    * @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
    *     0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
    *     parent commit of a merge commit
-   * @return the commit SHA-1
-   * @throws PatchListNotAvailableException if the SHA-1 is unavailable for an unknown reason
+   * @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
+   *     change/patchset
+   * @throws StorageException if the SHA-1 is unavailable for an unknown reason
    */
-  public static ObjectId determineCommitId(
-      PatchListCache patchListCache, Change change, PatchSet patchset, short side)
-      throws PatchListNotAvailableException {
+  public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
     if (Side.fromShort(side) == Side.PARENT) {
       if (side < 0) {
-        return patchListCache.getOldId(change, patchset, -side);
-      } else {
-        return patchListCache.getOldId(change, patchset, null);
+        int parentNumber = Math.abs(side);
+        return resolveParentCommit(change.getProject(), patchset, parentNumber);
       }
-    } else {
-      return patchset.commitId();
+      return Optional.of(resolveAutoMergeCommit(change, patchset));
+    }
+    return Optional.of(patchset.commitId());
+  }
+
+  private Optional<ObjectId> resolveParentCommit(
+      Project.NameKey project, PatchSet patchset, int parentNumber) {
+    try (Repository repository = repoManager.openRepository(project)) {
+      RevCommit commit = repository.parseCommit(patchset.commitId());
+      if (commit.getParentCount() < parentNumber) {
+        return Optional.empty();
+      }
+      return Optional.of(commit.getParent(parentNumber - 1));
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
+    try {
+      // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
+      // unignore the test in PortedCommentsIT.
+      return patchListCache.getOldId(change, patchset, null);
+    } catch (PatchListNotAvailableException e) {
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 658af15..4d19dd0 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -22,15 +22,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
@@ -44,16 +41,13 @@
 public class PublishCommentUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final CommentsUtil commentsUtil;
 
   @Inject
-  PublishCommentUtil(
-      CommentsUtil commentsUtil, PatchListCache patchListCache, PatchSetUtil psUtil) {
+  PublishCommentUtil(CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   public void publish(
@@ -101,11 +95,7 @@
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(draftComment::setRealAuthor);
-      try {
-        CommentsUtil.setCommentCommitId(draftComment, patchListCache, notes.getChange(), ps);
-      } catch (PatchListNotAvailableException e) {
-        throw new StorageException(e);
-      }
+      commentsUtil.setCommentCommitId(draftComment, notes.getChange(), ps);
       commentsToPublish.add(draftComment);
     }
     commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index e03c244..358ce92 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
@@ -47,7 +46,7 @@
  * <p>This class uses the {@link PublishCommentUtil} to publish draft comments and fires the
  * necessary event for this.
  */
-public class PublishCommentsOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class PublishCommentsOp implements BatchUpdateOp {
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeMessagesUtil cmUtil;
@@ -62,8 +61,6 @@
   private List<HumanComment> comments = new ArrayList<>();
   private ChangeMessage message;
   private IdentifiedUser user;
-  private ChangeNotes changeNotes;
-  private PatchSet patchset;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -112,24 +109,11 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
-    patchset = psUtil.get(changeNotes, psId);
-
-    commentAdded.fire(
-        changeNotes.getChange(),
-        patchset,
-        ctx.getAccount(),
-        message.getMessage(),
-        ImmutableMap.of(),
-        ImmutableMap.of(),
-        ctx.getWhen());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
     if (message == null || comments.isEmpty()) {
       return;
     }
+    ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
+    PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
       RepoView repoView;
@@ -140,10 +124,17 @@
             String.format("Repository %s not found", ctx.getProject().get()), ex);
       }
       email
-          .create(
-              notify, changeNotes, patchset, user, message, comments, null, labelDelta, repoView)
-          .send();
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
+    commentAdded.fire(
+        changeNotes.getChange(),
+        ps,
+        ctx.getAccount(),
+        message.getMessage(),
+        ImmutableMap.of(),
+        ImmutableMap.of(),
+        ctx.getWhen());
   }
 
   private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index e0e489b..6c39ed0 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -30,14 +30,13 @@
 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.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
-public class AbandonOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AbandonOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AbandonedSender.Factory abandonedSenderFactory;
@@ -52,7 +51,6 @@
   private Change change;
   private PatchSet patchSet;
   private ChangeMessage message;
-  private NotifyResolver.Result notify;
 
   public interface Factory {
     AbandonOp create(
@@ -98,7 +96,6 @@
     update.setStatus(change.getStatus());
     message = newMessage(ctx);
     cmUtil.addChangeMessage(update, message);
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
@@ -115,11 +112,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
           abandonedSenderFactory.create(ctx.getProject(), change.getId());
@@ -134,5 +127,6 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
+    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 5e48353..4a3f638 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -23,27 +23,34 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 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;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
 @Singleton
 public class AddReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
-      AddReviewerSender.Factory addReviewerSenderFactory, MessageIdGenerator messageIdGenerator) {
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
 
-  public void emailReviewers(
+  public void emailReviewersAsync(
       IdentifiedUser user,
       Change change,
       Collection<Account.Id> added,
@@ -71,20 +78,27 @@
     ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
     ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
 
-    try {
-      AddReviewerSender emailSender = addReviewerSenderFactory.create(projectNameKey, cId);
-      emailSender.setNotify(notify);
-      emailSender.setFrom(userId);
-      emailSender.addReviewers(immutableToMail);
-      emailSender.addReviewersByEmail(immutableAddedByEmail);
-      emailSender.addExtraCC(immutableToCopy);
-      emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(change.getProject(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot send email to new reviewers of change %s", change.getId());
-    }
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailsExecutor.submit(
+            () -> {
+              try {
+                AddReviewerSender emailSender =
+                    addReviewerSenderFactory.create(projectNameKey, cId);
+                emailSender.setNotify(notify);
+                emailSender.setFrom(userId);
+                emailSender.addReviewers(immutableToMail);
+                emailSender.addReviewersByEmail(immutableAddedByEmail);
+                emailSender.addExtraCC(immutableToCopy);
+                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
+                emailSender.send();
+              } catch (Exception err) {
+                logger.atSevere().withCause(err).log(
+                    "Cannot send email to new reviewers of change %s", change.getId());
+              }
+            });
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index fd57722..ff8e5c6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -53,7 +52,7 @@
 import java.util.List;
 import java.util.Set;
 
-public class AddReviewersOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AddReviewersOp implements BatchUpdateOp {
   public interface Factory {
 
     /**
@@ -239,7 +238,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     opResult =
         Result.builder()
             .setAddedReviewers(addedReviewers)
@@ -247,20 +246,8 @@
             .setAddedCCs(addedCCs)
             .setAddedCCsByEmail(addedCCsByEmail)
             .build();
-    if (!addedReviewers.isEmpty()) {
-      List<AccountState> reviewers =
-          addedReviewers.stream()
-              .map(r -> accountCache.get(r.accountId()))
-              .flatMap(Streams::stream)
-              .collect(toList());
-      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-    }
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
     if (sendEmail) {
-      addReviewersEmail.emailReviewers(
+      addReviewersEmail.emailReviewersAsync(
           ctx.getUser().asIdentifiedUser(),
           change,
           Lists.transform(addedReviewers, PatchSetApproval::accountId),
@@ -269,6 +256,14 @@
           addedCCsByEmail,
           ctx.getNotify(change.getId()));
     }
+    if (!addedReviewers.isEmpty()) {
+      List<AccountState> reviewers =
+          addedReviewers.stream()
+              .map(r -> accountCache.get(r.accountId()))
+              .flatMap(Streams::stream)
+              .collect(toList());
+      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
   }
 
   public Result getResult() {
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 31147fc..8053b30 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -35,7 +34,7 @@
 import java.io.IOException;
 
 /** Add a specified user to the attention set. */
-public class AddToAttentionSetOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AddToAttentionSetOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -102,7 +101,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     if (!notify) {
       return;
     }
@@ -115,7 +114,7 @@
               reason,
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
               attentionUserId)
-          .send();
+          .sendAsync();
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 2f95a5c..a086cb1 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -68,12 +69,12 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.InsertChangeOp;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -83,13 +84,15 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class ChangeInserter implements InsertChangeOp, AsyncPostUpdateOp {
+public class ChangeInserter implements InsertChangeOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -103,6 +106,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
@@ -126,6 +130,7 @@
   private List<String> groups = Collections.emptyList();
   private boolean validate = true;
   private Map<String, Short> approvals;
+  private RequestScopePropagator requestScopePropagator;
   private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
@@ -141,7 +146,6 @@
   private String pushCert;
   private ProjectState projectState;
   private ReviewerAdditionList reviewerAdditions;
-  private NotifyResolver.Result notify;
 
   @Inject
   ChangeInserter(
@@ -152,6 +156,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CreateChangeSender.Factory createChangeSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
@@ -168,6 +173,7 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.createChangeSenderFactory = createChangeSenderFactory;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
@@ -309,6 +315,11 @@
     return this;
   }
 
+  public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
+    this.requestScopePropagator = r;
+    return this;
+  }
+
   public ChangeInserter setRevertOf(Change.Id revertOf) {
     this.revertOf = revertOf;
     return this;
@@ -446,13 +457,57 @@
               ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(update, changeMessage);
     }
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
+    if (sendMail && notify.shouldNotify()) {
+      Runnable sender =
+          new Runnable() {
+            @Override
+            public void run() {
+              try {
+                CreateChangeSender emailSender =
+                    createChangeSenderFactory.create(change.getProject(), change.getId());
+                emailSender.setFrom(change.getOwner());
+                emailSender.setPatchSet(patchSet, patchSetInfo);
+                emailSender.setNotify(notify);
+                emailSender.addReviewers(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId)
+                        .collect(toImmutableSet()));
+                emailSender.addReviewersByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                emailSender.addExtraCC(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                emailSender.addExtraCCByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                emailSender.send();
+              } catch (Exception e) {
+                logger.atSevere().withCause(e).log(
+                    "Cannot send email for new change %s", change.getId());
+              }
+            }
+
+            @Override
+            public String toString() {
+              return "send-email newchange";
+            }
+          };
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+      } else {
+        sender.run();
+      }
+    }
+
     /* For labels that are not set in this operation, show the "current" value
      * of 0, and no oldValue as the value was not modified by this operation.
      * For labels that are set in this operation, the value was modified, so
@@ -480,34 +535,6 @@
     }
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    reviewerAdditions.asyncPostUpdate(ctx);
-    if (sendMail && notify.shouldNotify()) {
-      try {
-        CreateChangeSender emailSender =
-            createChangeSenderFactory.create(change.getProject(), change.getId());
-        emailSender.setFrom(change.getOwner());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setNotify(notify);
-        emailSender.addReviewers(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                .map(PatchSetApproval::accountId)
-                .collect(toImmutableSet()));
-        emailSender.addReviewersByEmail(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
-        emailSender.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
-        emailSender.addExtraCCByEmail(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot send email for new change %s", change.getId());
-      }
-    }
-  }
-
   private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
     if (!validate) {
       return;
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index d9ede58..255e13a 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -22,7 +22,6 @@
 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.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -30,7 +29,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collections;
 
-public class DeleteReviewerByEmailOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -74,7 +73,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     try {
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
       if (!notify.shouldNotify()) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index de11e07..07cb04f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -56,7 +55,7 @@
 import java.util.HashMap;
 import java.util.Map;
 
-public class DeleteReviewerOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class DeleteReviewerOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -77,12 +76,11 @@
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
 
-  private ChangeMessage changeMessage;
-  private Change currChange;
-  private PatchSet currPs;
-  private Map<String, Short> newApprovals = new HashMap<>();
-  private Map<String, Short> oldApprovals = new HashMap<>();
-  private NotifyResolver.Result notify;
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
 
   @Inject
   DeleteReviewerOp(
@@ -167,27 +165,13 @@
     changeMessage =
         ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     cmUtil.addChangeMessage(update, changeMessage);
-    notify = ctx.getNotify(currChange.getId());
 
     return true;
   }
 
   @Override
   public void postUpdate(Context ctx) {
-    reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
-        ctx.getAccount(),
-        changeMessage.getMessage(),
-        newApprovals,
-        oldApprovals,
-        notify.handling(),
-        ctx.getWhen());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
     if (input.notify == null
         && currChange.isWorkInProgress()
         && !oldApprovals.isEmpty()
@@ -203,6 +187,16 @@
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
     }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        notify.handling(),
+        ctx.getWhen());
   }
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 309b041..cacfbe7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -21,18 +21,24 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.CurrentUser;
 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;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-public class EmailReviewComments {
+public class EmailReviewComments implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -65,8 +71,10 @@
         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;
@@ -81,8 +89,10 @@
 
   @Inject
   EmailReviewComments(
+      @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
+      ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
@@ -93,8 +103,10 @@
       @Nullable @Assisted String patchSetComment,
       @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;
@@ -107,7 +119,14 @@
     this.repoView = repoView;
   }
 
-  public void send() {
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    RequestContext old = requestContext.setContext(this);
     try {
       CommentSender emailSender =
           commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
@@ -124,6 +143,18 @@
       emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+    } finally {
+      requestContext.setContext(old);
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return user.getRealUser();
+  }
 }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 4502408..882352d 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -49,7 +49,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -63,7 +62,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class PatchSetInserter implements BatchUpdateOp, AsyncPostUpdateOp {
+public class PatchSetInserter implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -110,7 +109,6 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
-  private NotifyResolver.Result notify;
 
   @Inject
   public PatchSetInserter(
@@ -281,12 +279,12 @@
         throw new BadRequestException(ex.getMessage());
       }
     }
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(changeMessage);
       try {
@@ -306,10 +304,7 @@
             "Cannot send email for new patch set on change %s", change.getId());
       }
     }
-  }
 
-  @Override
-  public void postUpdate(Context ctx) {
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a8cc75a..231359b 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -49,7 +48,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class RebaseChangeOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
   }
@@ -222,11 +221,6 @@
     patchSetInserter.postUpdate(ctx);
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    patchSetInserter.asyncPostUpdate(ctx);
-  }
-
   public RevCommit getRebasedCommit() {
     checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 7f4e0e3..e532409 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -37,7 +36,7 @@
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
-public class RemoveFromAttentionSetOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class RemoveFromAttentionSetOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -102,7 +101,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     if (!notify) {
       return;
     }
@@ -115,7 +114,7 @@
               reason,
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
               attentionUserId)
-          .send();
+          .sendAsync();
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 3638bf2..3d986d2 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -587,7 +587,7 @@
       }
     }
 
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       for (ReviewerAddition addition : additions()) {
         if (addition.op != null) {
           addition.op.postUpdate(ctx);
@@ -595,14 +595,6 @@
       }
     }
 
-    public void asyncPostUpdate(Context ctx) {
-      for (ReviewerAddition addition : additions()) {
-        if (addition.op != null) {
-          addition.op.asyncPostUpdate(ctx);
-        }
-      }
-    }
-
     public <T> ImmutableSet<T> flattenResults(
         Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
       additions()
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index c2e36f5..411c9b6 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -38,7 +37,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
-public class SetAssigneeOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class SetAssigneeOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -121,7 +120,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,10 +133,6 @@
       logger.atSevere().withCause(err).log(
           "Cannot send email to new assignee of change %s", change.getId());
     }
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
     assigneeChanged.fire(
         change, ctx.getAccount(), oldAssignee != null ? oldAssignee.state() : null, ctx.getWhen());
   }
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 99f7adb..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -38,7 +37,7 @@
 import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
-public class WorkInProgressOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class WorkInProgressOp implements BatchUpdateOp {
   public static class Input extends InputWithMessage {
     @Nullable public NotifyHandling notify;
 
@@ -127,7 +126,8 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
         || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
@@ -152,11 +152,6 @@
             cmsg.getMessage(),
             ImmutableList.of(),
             repoView)
-        .send();
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java b/java/com/google/gerrit/server/config/SendEmailExecutor.java
similarity index 90%
rename from java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java
rename to java/com/google/gerrit/server/config/SendEmailExecutor.java
index 7951b8c..cf90cbf 100644
--- a/java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java
+++ b/java/com/google/gerrit/server/config/SendEmailExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2014 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -23,4 +23,4 @@
 /** Marker on the global {@link ScheduledThreadPoolExecutor} used to send email. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface AsyncPostUpdateExecutor {}
+public @interface SendEmailExecutor {}
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 5caf6fe..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -51,20 +51,14 @@
 
   @Provides
   @Singleton
-  @AsyncPostUpdateExecutor
+  @SendEmailExecutor
   public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
-    // sendemail.threadPoolSize is deprecated and overridden by asyncPostUpdate.threadPoolSize.
-    int poolSize =
-        config.getInt(
-            "asyncPostUpdate",
-            null,
-            "threadPoolSize",
-            config.getInt("sendemail", null, "threadPoolSize", 1));
+    int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
       return newDirectExecutorService();
     }
-    return queues.createQueue(poolSize, "AsyncPostUpdate", true);
+    return queues.createQueue(poolSize, "SendEmail", true);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 67c7f01..47cbd60 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -301,7 +300,7 @@
     return changeId;
   }
 
-  private class NotifyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
 
@@ -311,12 +310,8 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 13625bc..40e2730 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -25,19 +25,22 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
+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.AsyncPostUpdateOp;
 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.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -49,21 +52,24 @@
  * <p>When we find a change corresponding to a commit that is pushed to a branch directly, we close
  * the change. This class marks the change as merged, and sends out the email notification.
  */
-public class MergedByPushOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class MergedByPushOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
     MergedByPushOp create(
+        RequestScopePropagator requestScopePropagator,
         PatchSet.Id psId,
         @Assisted SubmissionId submissionId,
         @Assisted("refName") String refName,
         @Assisted("mergeResultRevId") String mergeResultRevId);
   }
 
+  private final RequestScopePropagator requestScopePropagator;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ChangeMessagesUtil cmUtil;
   private final MergedSender.Factory mergedSenderFactory;
   private final PatchSetUtil psUtil;
+  private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -84,8 +90,10 @@
       ChangeMessagesUtil cmUtil,
       MergedSender.Factory mergedSenderFactory,
       PatchSetUtil psUtil,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
       MessageIdGenerator messageIdGenerator,
+      @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
       @Assisted("refName") String refName,
@@ -94,8 +102,10 @@
     this.cmUtil = cmUtil;
     this.mergedSenderFactory = mergedSenderFactory;
     this.psUtil = psUtil;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
     this.messageIdGenerator = messageIdGenerator;
+    this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
     this.refName = refName;
@@ -171,27 +181,36 @@
     if (!correctBranch) {
       return;
     }
+    @SuppressWarnings("unused") // Runnable already handles errors
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator.wrap(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    try {
+                      MergedSender emailSender =
+                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
+                      emailSender.setFrom(ctx.getAccountId());
+                      emailSender.setPatchSet(patchSet, info);
+                      emailSender.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                      emailSender.send();
+                    } catch (Exception e) {
+                      logger.atSevere().withCause(e).log(
+                          "Cannot send email for submitted patch set %s", psId);
+                    }
+                  }
+
+                  @Override
+                  public String toString() {
+                    return "send-email merged";
+                  }
+                }));
+
     changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    if (!correctBranch) {
-      return;
-    }
-
-    try {
-      MergedSender emailSender = mergedSenderFactory.create(ctx.getProject(), psId.changeId());
-      emailSender.setFrom(ctx.getAccountId());
-      emailSender.setPatchSet(patchSet, info);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot send email for submitted patch set %s", psId);
-    }
-  }
-
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
     RevCommit commit = rw.parseCommit(requireNonNull(patchSet).commitId());
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index e44f422..015ed0b 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -175,6 +175,7 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
@@ -338,6 +339,7 @@
   private final PluginSetContext<RequestListener> requestListeners;
   private final PublishCommentsOp.Factory publishCommentsOp;
   private final RetryHelper retryHelper;
+  private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
   private final SubmoduleOp.Factory subOpFactory;
@@ -419,6 +421,7 @@
       ReplaceOp.Factory replaceOpFactory,
       PluginSetContext<RequestListener> requestListeners,
       RetryHelper retryHelper,
+      RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
       SubmoduleOp.Factory subOpFactory,
@@ -467,6 +470,7 @@
     this.replaceOpFactory = replaceOpFactory;
     this.requestListeners = requestListeners;
     this.retryHelper = retryHelper;
+    this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
@@ -2582,6 +2586,7 @@
                       magicBranch.getCombinedCcs(fromFooters))
                   .setApprovals(approvals)
                   .setMessage(msg.toString())
+                  .setRequestScopePropagator(requestScopePropagator)
                   .setSendMail(true)
                   .setPatchSetDescription(magicBranch.message));
           if (!magicBranch.hashtags.isEmpty()) {
@@ -3008,20 +3013,22 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory.create(
-                projectState,
-                notes.getChange().getDest(),
-                checkMergedInto,
-                checkMergedInto ? inputCommand.getNewId().name() : null,
-                priorPatchSet,
-                priorCommit,
-                psId,
-                newCommit,
-                info,
-                groups,
-                magicBranch,
-                receivePack.getPushCertificate(),
-                notes.getChange());
+            replaceOpFactory
+                .create(
+                    projectState,
+                    notes.getChange().getDest(),
+                    checkMergedInto,
+                    checkMergedInto ? inputCommand.getNewId().name() : null,
+                    priorPatchSet,
+                    priorCommit,
+                    psId,
+                    newCommit,
+                    info,
+                    groups,
+                    magicBranch,
+                    receivePack.getPushCertificate(),
+                    notes.getChange())
+                .setRequestScopePropagator(requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3302,7 +3309,11 @@
                           bu.addOp(
                               psId.changeId(),
                               mergedByPushOpFactory.create(
-                                  psId, submissionId, refName, newTip.getId().getName()));
+                                  requestScopePropagator,
+                                  psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName()));
                           continue COMMIT;
                         }
                       }
@@ -3346,7 +3357,12 @@
                       bu.addOp(
                           id,
                           mergedByPushOpFactory
-                              .create(req.psId, submissionId, refName, newTip.getId().getName())
+                              .create(
+                                  requestScopePropagator,
+                                  req.psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName())
                               .setPatchSetProvider(req.replaceOp::getPatchSet));
                       bu.addOp(id, new ChangeProgressOp(progress));
                       ids.add(id);
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 87266f3..ce62d7a 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -70,11 +71,11 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 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.RepoContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -85,6 +86,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -93,7 +96,7 @@
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class ReplaceOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class ReplaceOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -120,6 +123,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
+  private final ExecutorService sendEmailExecutor;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
@@ -153,6 +157,7 @@
   private ChangeMessage msg;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
   private ReviewerAdditionList reviewerAdditions;
   private MailRecipients oldRecipients;
 
@@ -169,6 +174,7 @@
       PatchSetUtil psUtil,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
       MessageIdGenerator messageIdGenerator,
@@ -196,6 +202,7 @@
     this.psUtil = psUtil;
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
@@ -232,7 +239,11 @@
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
-                patchSetId, new SubmissionId(change), mergedInto, mergeResultRevId);
+                requestScopePropagator,
+                patchSetId,
+                new SubmissionId(change),
+                mergedInto,
+                mergeResultRevId);
       }
     }
 
@@ -482,8 +493,18 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
+    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
+      // TODO(dborowitz): Merge email templates so we only have to send one.
+      Runnable e = new ReplaceEmailTask(ctx);
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
+      } else {
+        e.run();
+      }
+    }
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
@@ -496,11 +517,15 @@
     }
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    reviewerAdditions.asyncPostUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
+  private class ReplaceEmailTask implements Runnable {
+    private final Context ctx;
+
+    private ReplaceEmailTask(Context ctx) {
+      this.ctx = ctx;
+    }
+
+    @Override
+    public void run() {
       try {
         ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
@@ -528,8 +553,10 @@
             "Cannot send email for new patch set %s", newPatchSet.id());
       }
     }
-    if (mergedByPushOp != null) {
-      mergedByPushOp.asyncPostUpdate(ctx);
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
     }
   }
 
@@ -586,6 +613,11 @@
     return cmd;
   }
 
+  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index b8c6bf0..df38118 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -59,12 +59,9 @@
 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;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -107,7 +104,6 @@
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
   private final OneOffRequestContext oneOffRequestContext;
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final Provider<InternalChangeQuery> queryProvider;
   private final DynamicMap<MailFilter> mailFilters;
@@ -127,7 +123,6 @@
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
       OneOffRequestContext oneOffRequestContext,
-      PatchListCache patchListCache,
       PatchSetUtil psUtil,
       Provider<InternalChangeQuery> queryProvider,
       DynamicMap<MailFilter> mailFilters,
@@ -144,7 +139,6 @@
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
     this.oneOffRequestContext = oneOffRequestContext;
-    this.patchListCache = patchListCache;
     this.psUtil = psUtil;
     this.queryProvider = queryProvider;
     this.mailFilters = mailFilters;
@@ -315,7 +309,7 @@
     }
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
     private final List<MailComment> parsedComments;
     private final String tag;
@@ -331,8 +325,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws UnprocessableEntityException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws UnprocessableEntityException {
       patchSet = psUtil.get(ctx.getNotes(), psId);
       notes = ctx.getNotes();
       if (patchSet == null) {
@@ -360,26 +353,6 @@
 
     @Override
     public void postUpdate(Context ctx) throws Exception {
-      // Get previous approvals from this user
-      Map<String, Short> approvals = new HashMap<>();
-      approvalsUtil
-          .byPatchSetUser(
-              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
-          .forEach(a -> approvals.put(a.label(), a.value()));
-      // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
-      // are always the same here.
-      commentAdded.fire(
-          notes.getChange(),
-          patchSet,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          approvals,
-          approvals,
-          ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) throws Exception {
       String patchSetComment = null;
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         patchSetComment = parsedComments.get(0).getMessage();
@@ -396,7 +369,23 @@
               patchSetComment,
               ImmutableList.of(),
               ctx.getRepoView())
-          .send();
+          .sendAsync();
+      // Get previous approvals from this user
+      Map<String, Short> approvals = new HashMap<>();
+      approvalsUtil
+          .byPatchSetUser(
+              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .forEach(a -> approvals.put(a.label(), a.value()));
+      // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
+      // are always the same here.
+      commentAdded.fire(
+          notes.getChange(),
+          patchSet,
+          ctx.getAccount(),
+          changeMessage.getMessage(),
+          approvals,
+          approvals,
+          ctx.getWhen());
     }
 
     private ChangeMessage generateChangeMessage(ChangeContext ctx) {
@@ -424,8 +413,7 @@
     }
 
     private HumanComment persistentCommentFromMailComment(
-        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
-        throws PatchListNotAvailableException {
+        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment) {
       String fileName;
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
@@ -457,7 +445,7 @@
         comment.range = mailComment.getInReplyTo().range;
         comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index f29c6e6..70d1eaf 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -40,8 +39,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -80,7 +77,6 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComments(
@@ -92,8 +88,7 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -103,7 +98,6 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -176,14 +170,13 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws PatchListNotAvailableException, PermissionBackendException {
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
       for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
         commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
         comments.add(humanCommentFormatter.format(c));
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 05241e2..e2abf29 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -67,10 +67,12 @@
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
   private final PatchListCache patchListCache;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache) {
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
     this.patchListCache = patchListCache;
+    this.commentsUtil = commentsUtil;
   }
 
   /**
@@ -131,13 +133,21 @@
       ImmutableList<HumanComment> patchsetComments = commentsPerPatchset.get(originalPatchsetId);
       PatchSet originalPatchset =
           notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId));
-      portedComments.addAll(
-          portSamePatchset(
-              notes.getProjectName(),
-              notes.getChange(),
-              originalPatchset,
-              targetPatchset,
-              patchsetComments));
+      if (originalPatchset != null) {
+        portedComments.addAll(
+            portSamePatchset(
+                notes.getProjectName(),
+                notes.getChange(),
+                originalPatchset,
+                targetPatchset,
+                patchsetComments));
+      } else {
+        logger.atWarning().log(
+            String.format(
+                "Some comments which should be ported refer to the non-existent patchset %s of"
+                    + " change %d. Omitting %d affected comments.",
+                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+      }
     }
     return portedComments.build();
   }
@@ -200,13 +210,22 @@
       PatchSet targetPatchset,
       short side)
       throws PatchListNotAvailableException {
-    ObjectId originalCommit =
-        CommentsUtil.determineCommitId(patchListCache, change, originalPatchset, side);
-    ObjectId targetCommit =
-        CommentsUtil.determineCommitId(patchListCache, change, targetPatchset, side);
+    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
     return loadCommitMappings(project, originalCommit, targetCommit);
   }
 
+  private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
+    return commentsUtil
+        .determineCommitId(change, patchset, side)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Commit indicated by change %d, patchset %d, side %d doesn't exist.",
+                        change.getId().get(), patchset.id().get(), side)));
+  }
+
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
       throws PatchListNotAvailableException {
@@ -277,6 +296,11 @@
       // No line -> use 0 = file comment or any other comment type without an explicit line.
       portedComment.lineNbr = newPosition.lineRange().map(range -> range.start() + 1).orElse(0);
     }
+    if (Patch.PATCHSET_LEVEL.equals(portedComment.key.filename)) {
+      // Correct the side of the comment to Side.REVISION (= 1) if the comment was changed to
+      // patchset level.
+      portedComment.side = 1;
+    }
     return portedComment;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 399da8f..8476767 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -51,20 +48,17 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   CreateDraftComment(
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -111,8 +105,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, UnprocessableEntityException,
-            PatchListNotAvailableException {
+        throws ResourceNotFoundException, UnprocessableEntityException {
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
@@ -133,7 +126,7 @@
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
 
       commentsUtil.putHumanComments(
           ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 71fd4d2..51a0b8e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
-
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
@@ -28,8 +26,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -45,18 +41,13 @@
   private final BatchUpdate.Factory updateFactory;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(
-      BatchUpdate.Factory updateFactory,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      BatchUpdate.Factory updateFactory, CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -79,8 +70,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
@@ -92,7 +82,7 @@
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       HumanComment c = maybeComment.get();
-      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(c, ctx.getChange(), ps);
       commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 22c3567..4b813df 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -147,7 +146,7 @@
     return Response.none();
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final ProjectState projectState;
     private final AccountState accountState;
     private final String label;
@@ -222,30 +221,17 @@
 
     @Override
     public void postUpdate(Context ctx) {
-      voteDeleted.fire(
-          change,
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          changeMessage.getMessage(),
-          ctx.getIdentifiedUser().state(),
-          ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       if (changeMessage == null) {
         return;
       }
 
+      IdentifiedUser user = ctx.getIdentifiedUser();
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
         if (notify.shouldNotify()) {
           ReplyToChangeSender emailSender =
               deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(ctx.getIdentifiedUser().getAccountId());
+          emailSender.setFrom(user.getAccountId());
           emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
           emailSender.setNotify(notify);
           emailSender.setMessageId(
@@ -255,6 +241,17 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
+
+      voteDeleted.fire(
+          change,
+          ps,
+          accountState,
+          newApprovals,
+          oldApprovals,
+          input.notify,
+          changeMessage.getMessage(),
+          user.state(),
+          ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 32c1656..3367ca6 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -120,7 +119,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -449,7 +447,7 @@
           ccByEmail.addAll(addition.reviewersByEmail);
         }
       }
-      addReviewersEmail.emailReviewers(
+      addReviewersEmail.emailReviewersAsync(
           user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
     }
   }
@@ -871,7 +869,7 @@
     abstract Comment.Range range();
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final ProjectState projectState;
     private final PatchSet.Id psId;
     private final ReviewInput in;
@@ -894,7 +892,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceConflictException, UnprocessableEntityException, IOException,
-            PatchListNotAvailableException, CommentsRejectedException {
+            CommentsRejectedException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
@@ -917,7 +915,7 @@
     }
 
     @Override
-    public void asyncPostUpdate(Context ctx) {
+    public void postUpdate(Context ctx) {
       if (message == null) {
         return;
       }
@@ -935,20 +933,12 @@
                   in.message,
                   labelDelta,
                   ctx.getRepoView())
-              .send();
+              .sendAsync();
         } catch (IOException ex) {
           throw new StorageException(
               String.format("Repository %s not found", ctx.getProject().get()), ex);
         }
       }
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (message == null) {
-        return;
-      }
-
       commentAdded.fire(
           notes.getChange(),
           ps,
@@ -960,7 +950,7 @@
     }
 
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws PatchListNotAvailableException, CommentsRejectedException {
+        throws CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
       if (inputComments == null) {
         inputComments = Collections.emptyMap();
@@ -1012,7 +1002,7 @@
             comment.message = inputComment.message;
           }
 
-          setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
           comment.setLineNbrAndRange(inputComment.line, inputComment.range);
           comment.tag = in.tag;
 
@@ -1091,8 +1081,7 @@
       return !newRobotComments.isEmpty();
     }
 
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx)
-        throws PatchListNotAvailableException {
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -1112,8 +1101,7 @@
     }
 
     private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
-        throws PatchListNotAvailableException {
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
       RobotComment robotComment =
           commentsUtil.newRobotComment(
               ctx,
@@ -1128,7 +1116,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 39de43d..84a3d89 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -54,7 +51,6 @@
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
-  private final PatchListCache patchListCache;
 
   @Inject
   PutDraftComment(
@@ -62,14 +58,12 @@
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      Provider<CommentJson> commentJson,
-      PatchListCache patchListCache) {
+      Provider<CommentJson> commentJson) {
     this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -114,8 +108,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
@@ -143,7 +136,7 @@
         commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
       commentsUtil.putHumanComments(
           update,
           HumanComment.Status.DRAFT,
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 26b9d4c..7faf8e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -109,7 +108,7 @@
     return Response.ok(json.noOptions().format(op.change));
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final RestoreInput input;
 
     private Change change;
@@ -150,12 +149,6 @@
 
     @Override
     public void postUpdate(Context ctx) {
-      changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
@@ -167,6 +160,8 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
+      changeRestored.fire(
+          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index d128186..ca39a57 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -72,7 +72,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -601,7 +600,7 @@
     }
   }
 
-  private class NotifyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final Change.Id revertChangeId;
 
@@ -611,13 +610,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(
           change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c7671b0..4efa4c8 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -19,14 +19,22 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 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;
+import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-class EmailMerge {
+class EmailMerge implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   interface Factory {
@@ -38,7 +46,10 @@
         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;
@@ -49,14 +60,20 @@
 
   @Inject
   EmailMerge(
+      @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
+      ThreadLocalRequestContext requestContext,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
       @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
       @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.change = change;
@@ -65,7 +82,14 @@
     this.repoView = repoView;
   }
 
-  public void send() {
+  void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    RequestContext old = requestContext.setContext(this);
     try {
       MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
@@ -77,6 +101,21 @@
       emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
+    } finally {
+      requestContext.setContext(old);
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email merged";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    if (submitter != null) {
+      return identifiedUserFactory.create(submitter).getRealUser();
+    }
+    throw new OutOfScopeException("No user on email thread");
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 3629640..edc3725 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -275,13 +275,6 @@
         rebaseOp.postUpdate(ctx);
       }
     }
-
-    @Override
-    public void asyncPostUpdateImpl(Context ctx) {
-      if (rebaseOp != null) {
-        rebaseOp.asyncPostUpdate(ctx);
-      }
-    }
   }
 
   private class MergeIfNecessaryOp extends SubmitStrategyOp {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 201eba8..3cc566b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -61,7 +60,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-abstract class SubmitStrategyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+abstract class SubmitStrategyOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected final SubmitStrategy.Arguments args;
@@ -484,22 +483,8 @@
       }
     }
 
-    if (mergeResultRev != null && !args.dryrun) {
-      args.changeMerged.fire(
-          updatedChange,
-          mergedPatchSet,
-          args.accountCache.get(submitter.accountId()).orElse(null),
-          args.mergeTip.getCurrentTip().name(),
-          ctx.getWhen());
-    }
-  }
-
-  /**
-   * Assume the change must have been merged at this point, otherwise we would have failed in one of
-   * the other steps in postUpdate (which is done prior to this method).
-   */
-  @Override
-  public final void asyncPostUpdate(Context ctx) {
+    // Assume the change must have been merged at this point, otherwise we would
+    // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
           .create(
@@ -508,11 +493,18 @@
               submitter.accountId(),
               ctx.getNotify(getId()),
               ctx.getRepoView())
-          .send();
+          .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
     }
-    asyncPostUpdateImpl(ctx);
+    if (mergeResultRev != null && !args.dryrun) {
+      args.changeMerged.fire(
+          updatedChange,
+          mergedPatchSet,
+          args.accountCache.get(submitter.accountId()).orElse(null),
+          args.mergeTip.getCurrentTip().name(),
+          ctx.getWhen());
+    }
   }
 
   /**
@@ -537,12 +529,6 @@
   protected void postUpdateImpl(Context ctx) throws Exception {}
 
   /**
-   * @see #asyncPostUpdate(Context)
-   * @param ctx
-   */
-  protected void asyncPostUpdateImpl(Context ctx) {}
-
-  /**
    * Amend the commit with gitlink update
    *
    * @param commit
diff --git a/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java b/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java
deleted file mode 100644
index 9a989b7..0000000
--- a/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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.update;
-
-import com.google.gerrit.server.config.SysExecutorModule;
-
-/** Base interface for operations performed asynchronously as part of a {@link BatchUpdate}. */
-public interface AsyncPostUpdateOp {
-
-  /**
-   * Override this method to do something after the update e.g. send emails. This method will be
-   * invoked asynchronously, and when invoked, the invoking method will not wait for the async
-   * updates to finish. This method will be called after {@link BatchUpdateOp} operations and {@link
-   * RepoOnlyOp} are finished.
-   *
-   * <p>The maximum amount of threads in the thread pool is decided by
-   * asyncPostUpdate.threadPoolSize. When asyncPostUpdate.threadPoolSize is not specified, the
-   * deprecated sendemail.threadPoolSize is used (see {@link
-   * SysExecutorModule#provideSendEmailExecutor}). This is the case for legacy reasons, since in the
-   * past only some emails were sent async (and sendemail.threadPoolSize) was used, and now all
-   * emails (and possibly others) are done async, so asyncPostUpdate.threadPoolSize is used.
-   *
-   * @param ctx context
-   */
-  default void asyncPostUpdate(Context ctx) throws Exception {}
-}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 2fdc124..166e88d 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.config.AsyncPostUpdateExecutor;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -63,7 +62,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
@@ -77,7 +75,6 @@
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.TreeMap;
-import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -335,8 +332,6 @@
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeIndexer indexer;
   private final GitReferenceUpdated gitRefUpdated;
-  private final ThreadLocalRequestContext requestContext;
-  private final ExecutorService executorService;
 
   private final Project.NameKey project;
   private final CurrentUser user;
@@ -365,8 +360,6 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
-      ThreadLocalRequestContext requestContext,
-      @AsyncPostUpdateExecutor ExecutorService executorService,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
@@ -376,8 +369,6 @@
     this.updateManagerFactory = updateManagerFactory;
     this.indexer = indexer;
     this.gitRefUpdated = gitRefUpdated;
-    this.requestContext = requestContext;
-    this.executorService = executorService;
     this.project = project;
     this.user = user;
     this.when = when;
@@ -646,40 +637,23 @@
     return new ChangeContextImpl(notes);
   }
 
-  private void executePostOps() {
+  private void executePostOps() throws Exception {
     ContextImpl ctx = new ContextImpl();
     for (BatchUpdateOp op : ops.values()) {
-      postUpdate(ctx, op);
-      if (op instanceof AsyncPostUpdateOp) {
-        asyncPostUpdate(ctx, ((AsyncPostUpdateOp) op));
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
       }
     }
 
     for (RepoOnlyOp op : repoOnlyOps) {
-      postUpdate(ctx, op);
-      if (op instanceof AsyncPostUpdateOp) {
-        asyncPostUpdate(ctx, ((AsyncPostUpdateOp) op));
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
       }
     }
   }
 
-  /** Invoke the postUpdate methods synchronously. */
-  private void postUpdate(ContextImpl ctx, RepoOnlyOp op) {
-    try (TraceContext.TraceTimer ignored =
-        TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-      op.postUpdate(ctx);
-    } catch (Exception ex) {
-      logDebug(
-          String.format(
-              "postUpdate for project %s failed for user %s", ctx.getProject(), ctx.getUser()));
-    }
-  }
-
-  /** Invoke the asyncPostUpdate methods asynchronously. */
-  private void asyncPostUpdate(ContextImpl ctx, AsyncPostUpdateOp op) {
-    executorService.execute(new ExecuteAsyncPostUpdate(op, ctx, user, requestContext));
-  }
-
   private static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
diff --git a/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java b/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java
deleted file mode 100644
index e640ab1..0000000
--- a/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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.update;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-
-/** Executes {@link AsyncPostUpdateOp#asyncPostUpdate(Context)} on a specific op, asynchronously. */
-public class ExecuteAsyncPostUpdate implements Runnable, RequestContext {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final AsyncPostUpdateOp op;
-  private final Context ctx;
-  private final CurrentUser user;
-  private final ThreadLocalRequestContext threadLocalRequestContext;
-
-  ExecuteAsyncPostUpdate(
-      AsyncPostUpdateOp op,
-      Context ctx,
-      CurrentUser user,
-      ThreadLocalRequestContext threadLocalRequestContext) {
-    this.op = op;
-    this.ctx = ctx;
-    this.user = user;
-    this.threadLocalRequestContext = threadLocalRequestContext;
-  }
-
-  @Override
-  public void run() {
-    RequestContext old = threadLocalRequestContext.setContext(this);
-    try {
-      op.asyncPostUpdate(ctx);
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot perform async post update for repo %s and user %s",
-          ctx.getProject(), ctx.getAccount().account().getName());
-    } finally {
-      threadLocalRequestContext.setContext(old);
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "async-post-update";
-  }
-
-  @Override
-  public CurrentUser getUser() {
-    return user;
-  }
-}
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index f9b41c4..7e9c47e 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -30,11 +30,10 @@
   default void updateRepo(RepoContext ctx) throws Exception {}
 
   /**
-   * Override this method to do something after the update e.g. run hooks. This method will
-   * <strong>NOT</strong> be invoked asynchronously. This method will be finished before {@link
-   * AsyncPostUpdateOp#asyncPostUpdate} is called.
+   * Override this method to do something after the update e.g. send email or run hooks
    *
    * @param ctx context
    */
+  // TODO(dborowitz): Support async operations?
   default void postUpdate(Context ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 2b09c49..56b1dda 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -17,7 +17,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
@@ -25,8 +27,10 @@
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-public class AttentionSetEmail {
+public class AttentionSetEmail implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -51,6 +55,7 @@
         Account.Id attentionUserId);
   }
 
+  private ExecutorService sendEmailsExecutor;
   private AttentionSetSender sender;
   private Context ctx;
   private Change change;
@@ -61,12 +66,14 @@
 
   @Inject
   AttentionSetEmail(
+      @SendEmailExecutor ExecutorService executor,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
       @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
+    this.sendEmailsExecutor = executor;
     this.sender = sender;
     this.ctx = ctx;
     this.change = change;
@@ -75,7 +82,13 @@
     this.attentionUserId = attentionUserId;
   }
 
-  public void send() {
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
     try {
       AccountState accountState =
           ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
@@ -91,4 +104,14 @@
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return ctx.getUser();
+  }
 }
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index f73020a..fec9b27 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -128,7 +128,6 @@
   }
 
   public synchronized @Nullable Message peekMessage() {
-    waitForEmails();
     if (messagesRead >= messages.size()) {
       return null;
     }
@@ -136,14 +135,9 @@
   }
 
   public synchronized @Nullable Message nextMessage() {
-    waitForEmails();
     Message msg = peekMessage();
-    readOneMessage();
-    return msg;
-  }
-
-  public synchronized void readOneMessage() {
     messagesRead++;
+    return msg;
   }
 
   public ImmutableList<Message> getMessages() {
@@ -166,7 +160,7 @@
     // a single thread in tests (tricky because most callers just use the
     // default executor).
     for (WorkQueue.Task<?> task : workQueue.getTasks()) {
-      if (task.toString().contains("async-post-update")) {
+      if (task.toString().contains("send-email")) {
         try {
           task.get();
         } catch (ExecutionException | InterruptedException e) {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 693fd90..6c9fbed 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.AsyncPostUpdateExecutor;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
@@ -59,6 +58,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
@@ -279,7 +279,7 @@
 
   @Provides
   @Singleton
-  @AsyncPostUpdateExecutor
+  @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
     return newDirectExecutorService();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 0bfd525..d4affb7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -284,11 +284,6 @@
     String fileName = "a_new_file.txt";
     String fileContent = "First line\nSecond line\n";
     PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-
-    // Emails are sent here async which triggers cache hits, so we must wait until those email are
-    // actually sent.
-    sender.clear();
-
     String triplet = project.get() + "~master~" + result.getChangeId();
     CacheStats startPatch = cloneStats(fileCache.stats());
     CacheStats startIntra = cloneStats(intraCache.stats());
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 783fa49..d76d114 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -34,6 +34,8 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
@@ -43,6 +45,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import org.junit.Ignore;
 import org.junit.Test;
 
 public class PortedCommentsIT extends AbstractDaemonTest {
@@ -339,6 +342,51 @@
   }
 
   @Test
+  public void commentOnInvalidParentIsPorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid = newComment(patchset1Id).onSecondParentCommit().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(commentUuid1, commentUuid2);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionKeepTheirInvalidPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("not-existing file");
+  }
+
+  @Test
   public void portedCommentHasOriginalUuid() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId = changeOps.newChange().create();
@@ -998,6 +1046,42 @@
   }
 
   @Test
+  public void overlappingRangeCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(0)
+            .toLine(2)
+            .charOffset(3)
+            .ofFile("myFile")
+            .create();
+    String commentUuid2 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(1)
+            .toLine(2)
+            .charOffset(4)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).range().startLine().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).range().startLine().isEqualTo(3);
+  }
+
+  @Test
   public void portedLineCommentCanHandleAddedLines() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId =
@@ -1241,6 +1325,28 @@
   }
 
   @Test
+  public void overlappingLineCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).line().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).line().isEqualTo(3);
+  }
+
+  @Test
   public void portedFileCommentIsObliviousToAdjustedFileContent() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId =
@@ -1553,6 +1659,169 @@
     assertThat(portedComment).line().isGreaterThan(2);
   }
 
+  @Test
+  public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent1PatchsetId2 =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parent()
+            .patchset(parent1PatchsetId2)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+    assertThat(portedComment).side().isEqualTo(Side.PARENT);
+    assertThat(portedComment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  // TODO(ghareeb): Adjust implementation in CommentsUtil to use the new auto-merge code instead of
+  // PatchListCache#getOldId which returns the wrong result if a change isn't a merge commit.
+  @Ignore
+  public void
+      commentOnAutoMergeCommitBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+          throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  public void whitespaceOnlyModificationsAreAlsoConsideredWhenPorting() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").content("\nLine 1\n").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(1).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchsetId1).message("Confidential content").create();
+
+    getPortedComment(patchsetId2, commentUuid);
+    gApi.changes()
+        .id(changeId.get())
+        .revision(patchsetId1.get())
+        .comment(commentUuid)
+        .delete(new DeleteCommentInput());
+    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+
+    assertThat(portedComment).message().doesNotContain("Confidential content");
+  }
+
+  @Test
+  public void setOfPortedCommentsCanChangeOnRepeatedCalls() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid1 = newComment(patchsetId1).unresolved().create();
+
+    ImmutableList<CommentInfo> pastPortedComments = flatten(getPortedComments(patchsetId2));
+    // Set the existing comment thread to resolved, so it won't be ported anymore.
+    newComment(patchsetId1).parentUuid(commentUuid1).resolved().create();
+    // Create a new comment which should show up as ported comment.
+    String commentUuid2 = newComment(patchsetId1).create();
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
+
+    // Ensure that results are not cached between calls. This should not be necessary as the diffs
+    // are already cached. If we need to also cache the ported comments in the future, we'll need to
+    // identify ALL situations when the set of ported comments changes.
+    assertThat(portedComments).isNotEqualTo(pastPortedComments);
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid2);
+  }
+
   private TestCommentCreation.Builder newComment(PatchSet.Id patchsetId) {
     // Create unresolved comments by default as only those are ported. Tests get override the
     // unresolved state by explicitly setting it.
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index aafe9b9..23bcdec 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -412,17 +412,17 @@
     sender.clear();
     result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER_REVIEWERS);
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
 
     sender.clear();
     result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.ALL);
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
 
     sender.clear();
     result = pushTo(pushSpec + ",submit"); // default is notify = ALL
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
   }
 
   @Test
@@ -442,22 +442,19 @@
     PushOneCommit.Result result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-to=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.TO);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.TO);
 
     sender.clear();
     result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-cc=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.CC);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.CC);
 
     sender.clear();
     result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-bcc=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.BCC);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.BCC);
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
@@ -518,15 +515,15 @@
    *     sent as "To" and sometimes can be sent as "Cc".
    */
   private void assertThatEmailsForChangeCreationAndSubmitWereSent(
-      String changeId, TestAccount expected, @Nullable RecipientType expectedRecipientType) {
+      TestAccount expected, @Nullable RecipientType expectedRecipientType) {
     String expectedEmail = expected.email();
     String expectedFullName = expected.fullName();
     Address expectedAddress = Address.create(expectedFullName, expectedEmail);
     assertThat(sender.getMessages()).hasSize(2);
-    Message message = Iterables.getOnlyElement(sender.getMessages(changeId, "newchange"));
+    Message message = sender.getMessages().get(0);
     assertThat(message.body().contains("review")).isTrue();
     assertAddress(message, expectedAddress, expectedRecipientType);
-    message = Iterables.getOnlyElement(sender.getMessages(changeId, "merged"));
+    message = sender.getMessages().get(1);
     assertThat(message.rcpt()).containsExactly(expectedAddress);
     assertAddress(message, expectedAddress, expectedRecipientType);
     assertThat(message.body().contains("submitted")).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index e040860..a6bd5eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -28,7 +28,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -399,13 +398,13 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
-    Message m = Iterables.getOnlyElement(sender.getMessages(r.getChangeId(), "comment"));
+    Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has posted comments on this change.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
-    m = Iterables.getOnlyElement(sender.getMessages(r.getChangeId(), "newchange"));
+    m = messages.get(1);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 7570ce9..1a01184 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -124,14 +124,12 @@
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
-    FakeEmailSender.Message newPatchsetMessage =
-        Iterables.getOnlyElement(sender.getMessages(changeId, "newpatchset"));
+    FakeEmailSender.Message newPatchsetMessage = messages.get(0);
     assertThat(newPatchsetMessage.body()).contains("new patch set");
     assertThat(newPatchsetMessage.headers().get("Message-ID").toString())
         .doesNotContain("EmailReviewComments");
 
-    FakeEmailSender.Message newCommentsMessage =
-        Iterables.getOnlyElement(sender.getMessages(changeId, "comment"));
+    FakeEmailSender.Message newCommentsMessage = messages.get(1);
     assertThat(newCommentsMessage.body()).contains("has posted comments on this change");
     assertThat(newCommentsMessage.headers().get("Message-ID").toString())
         .contains("EmailReviewComments");
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 9c30fc9..8891cd2 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.Comparator.comparing;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyShort;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -31,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.PatchList;
@@ -41,6 +43,7 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
 import java.util.Arrays;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Rule;
 import org.junit.Test;
@@ -56,6 +59,7 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
   @Mock private PatchListCache patchListCache;
+  @Mock private CommentsUtil commentsUtil;
 
   private int uuidCounter = 0;
 
@@ -68,9 +72,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(PatchListNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
@@ -88,9 +93,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
@@ -100,7 +106,7 @@
   }
 
   @Test
-  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() throws Exception {
+  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() {
     Project.NameKey project = Project.nameKey("myProject");
     Change.Id changeId = Change.id(1);
     Change change = createChange(project, changeId);
@@ -108,9 +114,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenThrow(IllegalStateException.class);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
 
@@ -126,9 +133,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
@@ -149,18 +157,13 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
-    PatchList emptyDiff =
-        new PatchList(
-            dummyObjectId,
-            dummyObjectId,
-            false,
-            ComparisonType.againstOtherPatchSet(),
-            new PatchListEntry[0]);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
     // Throw an exception on the first diff request but return an actual value on the second.
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class)
@@ -173,6 +176,29 @@
     assertThat(portedComments).comparingElementsUsing(hasFilePath()).contains("myFile");
   }
 
+  @Test
+  public void commentsWithInvalidPatchsetsAreIgnored() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenReturn(emptyDiff);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    assertThat(portedComments).isEmpty();
+  }
+
   private Change createChange(Project.NameKey project, Change.Id changeId) {
     return new Change(
         Change.key("changeKey"),
@@ -224,4 +250,13 @@
   private Correspondence<HumanComment, String> hasFilePath() {
     return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
   }
+
+  private PatchList getEmptyDiff() {
+    return new PatchList(
+        dummyObjectId,
+        dummyObjectId,
+        false,
+        ComparisonType.againstOtherPatchSet(),
+        new PatchListEntry[0]);
+  }
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 7bcd833..b75c26d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -127,9 +127,9 @@
   /** @override */
   created() {
     super.created();
-    this.addEventListener('content-change', e =>
-      this._handleContentChange(e as CustomEvent<{value: string}>)
-    );
+    this.addEventListener('content-change', e => {
+      this._handleContentChange(e as CustomEvent<{value: string}>);
+    });
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 3b738a4..fbc62e2 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -135,7 +135,7 @@
 
       /* Stopgap solution until we remove hidden$ attributes. */
 
-      :host[hidden],
+      :host([hidden]),
       [hidden] {
         display: none !important;
       }