Merge "Fix AbstractSubmit test flakiness"
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 0c93b52..ade1287 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ChangeFinder;
@@ -1072,6 +1073,8 @@
   /**
    * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
    * resulting tree id.
+   *
+   * <p>Omits NoteDb meta refs.
    */
   protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
@@ -1105,11 +1108,12 @@
                   NullProgressMonitor.INSTANCE,
                   Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
           for (Ref r : fr.getAdvertisedRefs()) {
-            String branchName = r.getName();
-            Branch.NameKey n = new Branch.NameKey(proj, branchName);
-
+            String refName = r.getName();
+            if (RefNames.isNoteDbMetaRef(refName)) {
+              continue;
+            }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(n, c.getTree().copy());
+            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
           }
         }
       }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 0250db1..edf5420 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -130,6 +130,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index d8aa35c..b94b062 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -244,6 +245,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 26a91aa..5235b14 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -388,6 +389,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 65ad499..b65cc0e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.collect.Iterables;
@@ -145,6 +146,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     Change.Id id = change.getChange().getId();
     SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
new file mode 100644
index 0000000..9850f2e
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbOnlyIT extends AbstractDaemonTest {
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.DISABLE_CHANGE_REVIEW_DB);
+  }
+
+  @Test
+  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    String master = "refs/heads/master";
+    String backup = "refs/backup/master";
+    ObjectId master1 = getRef(master).get();
+    assertThat(getRef(backup)).isEmpty();
+
+    // Toy op that copies the value of refs/heads/master to refs/backup/master.
+    BatchUpdateOp backupMasterOp =
+        new BatchUpdateOp() {
+          ObjectId newId;
+
+          @Override
+          public void updateRepo(RepoContext ctx) throws IOException {
+            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
+            newId = ctx.getRepoView().getRef(master).get();
+            ctx.addRefUpdate(new ReceiveCommand(oldId, newId, backup));
+          }
+
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                .setChangeMessage("Backed up master branch to " + newId.name());
+            return true;
+          }
+        };
+
+    try (BatchUpdate bu = newBatchUpdate()) {
+      bu.addOp(id, backupMasterOp);
+      bu.execute();
+    }
+
+    // Ensure backupMasterOp worked.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
+
+    // Advance master by submitting the change.
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+    ObjectId master2 = getRef(master).get();
+    assertThat(master2).isNotEqualTo(master1);
+    int msgCount = getMessages(id).size();
+
+    try (BatchUpdate bu = newBatchUpdate()) {
+      // This time, we attempt to back up master, but we fail during updateChange.
+      bu.addOp(id, backupMasterOp);
+      String msg = "Change is bad";
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
+              throw new ResourceConflictException(msg);
+            }
+          });
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(msg);
+      }
+    }
+
+    // If updateChange hadn't failed, backup would have been updated to master2.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).hasSize(msgCount);
+  }
+
+  private BatchUpdate newBatchUpdate() {
+    return batchUpdateFactory.create(
+        db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  }
+
+  private Optional<ObjectId> getRef(String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
+    }
+  }
+
+  private List<String> getMessages(Change.Id id) throws Exception {
+    return gApi.changes()
+        .id(id.get())
+        .get(EnumSet.of(ListChangesOption.MESSAGES))
+        .messages
+        .stream()
+        .map(m -> m.message)
+        .collect(toList());
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b892e3d..8407bc6 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -105,6 +105,17 @@
     return r.toString();
   }
 
+  public static boolean isNoteDbMetaRef(String ref) {
+    if (ref.startsWith(REFS_CHANGES)
+        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
+      return true;
+    }
+    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
+      return true;
+    }
+    return false;
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
index 093ff21..7dec0fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
@@ -151,7 +152,9 @@
           for (ReceiveCommand r : refs) {
             bw.include(r.getRefName(), r.getNewId());
             ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())) {
+            if (!oldId.equals(ObjectId.zeroId())
+                // Probably the client doesn't already have NoteDb data.
+                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
               bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
             }
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 194372f..d23e856 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -168,12 +168,16 @@
     }
 
     void flush() throws IOException {
+      flushToFinalInserter();
+      finalIns.flush();
+      tempIns.clear();
+    }
+
+    void flushToFinalInserter() throws IOException {
       checkState(finalIns != null);
       for (InsertedObject obj : tempIns.getInsertedObjects()) {
         finalIns.insert(obj.type(), obj.data().toByteArray());
       }
-      finalIns.flush();
-      tempIns.clear();
     }
 
     @Override
@@ -317,7 +321,13 @@
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
-        && toDelete.isEmpty();
+        && toDelete.isEmpty()
+        && !hasCommands(changeRepo)
+        && !hasCommands(allUsersRepo);
+  }
+
+  private static boolean hasCommands(@Nullable OpenRepo or) {
+    return or != null && !or.cmds.isEmpty();
   }
 
   /**
@@ -385,6 +395,10 @@
       Set<Change.Id> changeIds = new HashSet<>();
       for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
         Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
+        if (changeId == null || !cmd.getRefName().equals(RefNames.changeMetaRef(changeId))) {
+          // Not a meta ref update, likely due to a repo update along with the change meta update.
+          continue;
+        }
         changeIds.add(changeId);
         Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
         staged.put(
@@ -450,13 +464,19 @@
     }
   }
 
-  public void execute() throws OrmException, IOException {
+  @Nullable
+  public BatchRefUpdate execute() throws OrmException, IOException {
+    return execute(false);
+  }
+
+  @Nullable
+  public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
     // Check before even inspecting the list, as this is a programmer error.
     if (migration.failChangeWrites()) {
       throw new OrmException(CHANGES_READ_ONLY);
     }
     if (isEmpty()) {
-      return;
+      return null;
     }
     try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
       stage();
@@ -468,36 +488,51 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      execute(changeRepo);
-      execute(allUsersRepo);
+      BatchRefUpdate result = execute(changeRepo, dryrun);
+      execute(allUsersRepo, dryrun);
+      return result;
     } finally {
       close();
     }
   }
 
-  private void execute(OpenRepo or) throws IOException {
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun) throws IOException {
     if (or == null || or.cmds.isEmpty()) {
-      return;
+      return null;
     }
-    or.flush();
+    if (!dryrun) {
+      or.flush();
+    } else {
+      // OpenRepo buffers objects separately; caller may assume that objects are available in the
+      // inserter it previously passed via setChangeRepo.
+      // TODO(dborowitz): This should be flushToFinalInserter to avoid flushing objects to the
+      // underlying repo during a dry run. However, the only user of this is PreviewSubmit, which
+      // uses BundleWriter, which only takes a Repository so it can't read unflushed objects. Fix
+      // BundleWriter, then fix this call.
+      or.flush();
+    }
+
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
     bru.setRefLogMessage(firstNonNull(refLogMessage, "Update NoteDb refs"), false);
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
-    bru.execute(or.rw, NullProgressMonitor.INSTANCE);
 
-    boolean lockFailure = false;
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure = true;
-      } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
+    if (!dryrun) {
+      bru.execute(or.rw, NullProgressMonitor.INSTANCE);
+      boolean lockFailure = false;
+      for (ReceiveCommand cmd : bru.getCommands()) {
+        if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+          lockFailure = true;
+        } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException("Update failed: " + bru);
+        }
+      }
+      if (lockFailure) {
+        throw new LockFailureException("Update failed with one or more lock failures: " + bru);
       }
     }
-    if (lockFailure) {
-      throw new LockFailureException("Update failed with one or more lock failures: " + bru);
-    }
+    return bru;
   }
 
   private void addCommands() throws OrmException, IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
index 674ecb1..f7988cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -17,11 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
 import com.google.common.util.concurrent.CheckedFuture;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -46,15 +44,13 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.TimeZone;
 import java.util.TreeMap;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -82,43 +78,64 @@
     setRequestIds(updates, requestId);
 
     try {
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
       Order order = getOrder(updates, listener);
-      // TODO(dborowitz): Fuse implementations to use a single BatchRefUpdate between phases. Note
-      // that we may still need to respect the order, since op implementations may make assumptions
-      // about the order in which their methods are called.
-      switch (order) {
-        case REPO_BEFORE_DB:
-          for (NoteDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          listener.afterUpdateRepos();
-          for (NoteDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          listener.afterUpdateRefs();
-          for (NoteDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
-          }
-          listener.afterUpdateChanges();
-          break;
-        case DB_BEFORE_REPO:
-          for (NoteDbBatchUpdate u : updates) {
-            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
-          }
-          for (NoteDbBatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          for (NoteDbBatchUpdate u : updates) {
-            u.executeRefUpdates(dryrun);
-          }
-          break;
-        default:
-          throw new IllegalStateException("invalid execution order: " + order);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
+
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
       }
 
-      ChangeIndexer.allAsList(
-              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
-          .get();
+      ChangeIndexer.allAsList(indexFutures).get();
 
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
@@ -250,8 +267,6 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final ReviewDb db;
 
-  private List<CheckedFuture<?, IOException>> indexFutures;
-
   @Inject
   NoteDbBatchUpdate(
       GitRepositoryManager repoManager,
@@ -275,7 +290,6 @@
     this.indexer = indexer;
     this.gitRefUpdated = gitRefUpdated;
     this.db = db;
-    this.indexFutures = new ArrayList<>();
   }
 
   @Override
@@ -309,101 +323,104 @@
         onSubmitValidators.validate(
             project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
       }
-
-      // TODO(dborowitz): Don't flush when fusing phases.
-      if (repoView != null) {
-        logDebug("Flushing inserter");
-        repoView.getInserter().flush();
-      } else {
-        logDebug("No objects to flush");
-      }
     } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, RestApiException.class);
       throw new UpdateException(e);
     }
   }
 
-  // TODO(dborowitz): Don't execute non-change ref updates separately when fusing phases.
-  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (getRefUpdates().isEmpty()) {
-      logDebug("No ref updates to execute");
-      return;
-    }
-    // May not be opened if the caller added ref updates but no new objects.
-    initRepository();
-    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
-    repoView.getCommands().addTo(batchRefUpdate);
-    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
-    if (dryrun) {
-      return;
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
     }
 
-    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
-    // that might have access to unflushed objects.
-    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
-      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    @Override
+    public void close() {
+      manager.close();
     }
-    boolean ok = true;
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        ok = false;
-        break;
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    List<CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
       }
-    }
-    if (!ok) {
-      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
+      logDebug("Reindexing {} changes", results.size());
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
     }
   }
 
-  private Map<Change.Id, ChangeResult> executeChangeOps(boolean dryrun) throws Exception {
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
     logDebug("Executing change ops");
-    Map<Change.Id, ChangeResult> result =
-        Maps.newLinkedHashMapWithExpectedSize(ops.keySet().size());
     initRepository();
-    Repository repo = repoView.getRepository();
-    // TODO(dborowitz): Teach NoteDbUpdateManager to allow reusing the same inserter and batch ref
-    // update as in executeUpdateRepo.
-    try (ObjectInserter ins = repo.newObjectInserter();
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader);
-        NoteDbUpdateManager updateManager =
+
+    ChangesHandle handle =
+        new ChangesHandle(
             updateManagerFactory
                 .create(project)
-                .setChangeRepo(repo, rw, ins, new ChainedReceiveCommands(repo))) {
-      if (user.isIdentifiedUser()) {
-        updateManager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+                .setChangeRepo(
+                    repoView.getRepository(),
+                    repoView.getRevWalk(),
+                    repoView.getInserter(),
+                    repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
       }
-      for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
-        Change.Id id = e.getKey();
-        ChangeContextImpl ctx = newChangeContext(id);
-        boolean dirty = false;
-        logDebug("Applying {} ops for change {}", e.getValue().size(), id);
-        for (BatchUpdateOp op : e.getValue()) {
-          dirty |= op.updateChange(ctx);
-        }
-        if (!dirty) {
-          logDebug("No ops reported dirty, short-circuiting");
-          result.put(id, ChangeResult.SKIPPED);
-          continue;
-        }
-        for (ChangeUpdate u : ctx.updates.values()) {
-          updateManager.add(u);
-        }
-        if (ctx.deleted) {
-          logDebug("Change {} was deleted", id);
-          updateManager.deleteChange(id);
-          result.put(id, ChangeResult.DELETED);
-        } else {
-          result.put(id, ChangeResult.UPSERTED);
-        }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
       }
-
-      if (!dryrun) {
-        logDebug("Executing NoteDb updates");
-        updateManager.execute();
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change {} was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
       }
     }
-    return result;
+    return handle;
   }
 
   private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
@@ -423,28 +440,6 @@
     return new ChangeContextImpl(ctl);
   }
 
-  private void reindexChanges(Map<Change.Id, ChangeResult> updateResults, boolean dryrun) {
-    if (dryrun) {
-      return;
-    }
-    logDebug("Reindexing {} changes", updateResults.size());
-    for (Map.Entry<Change.Id, ChangeResult> e : updateResults.entrySet()) {
-      Change.Id id = e.getKey();
-      switch (e.getValue()) {
-        case UPSERTED:
-          indexFutures.add(indexer.indexAsync(project, id));
-          break;
-        case DELETED:
-          indexFutures.add(indexer.deleteAsync(id));
-          break;
-        case SKIPPED:
-          break;
-        default:
-          throw new IllegalStateException("unexpected result: " + e.getValue());
-      }
-    }
-  }
-
   private void executePostOps() throws Exception {
     ContextImpl ctx = new ContextImpl();
     for (BatchUpdateOp op : ops.values()) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index f5eff27..8a317ab 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -528,7 +528,6 @@
 
         if (this.viewState.showReplyDialog) {
           this._openReplyDialog();
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
           this.set('viewState.showReplyDialog', false);
         }
       }.bind(this));
@@ -542,7 +541,7 @@
         // another, so that the user's preference is restored.
         this.set('viewState.diffMode', null);
         this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
-     }
+      }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
     },
@@ -838,6 +837,8 @@
       this.$.replyOverlay.open().then(function() {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
         this.$.replyDialog.open(opt_section);
+        Polymer.dom.flush();
+        this.$.replyOverlay.center();
       }.bind(this));
     },