Merge changes from topic 'retry-helper-1'

* changes:
  Add a helper for retrying BatchUpdates under NoteDb
  BatchUpdateOp: Clarify docs around reuse
  Pull BatchUpdate.Factory field up into AbstractDaemonTest
  AbstractDaemonTest: Reorder fields
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 f4ee56e..e7a9568 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
@@ -98,6 +98,7 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
@@ -164,84 +165,9 @@
   private static GerritServer commonServer;
 
   @ConfigSuite.Parameter public Config baseConfig;
-
   @ConfigSuite.Name private String configName;
 
-  @Inject protected AllProjectsName allProjects;
-
-  @Inject protected AccountCreator accounts;
-
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
-  @Inject protected GerritApi gApi;
-
-  @Inject protected AcceptanceTestRequestScope atrScope;
-
-  @Inject protected AccountCache accountCache;
-
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
-
-  @Inject protected PushOneCommit.Factory pushFactory;
-
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-
-  @Inject protected ProjectCache projectCache;
-
-  @Inject protected GroupCache groupCache;
-
-  @Inject protected GitRepositoryManager repoManager;
-
-  @Inject protected ChangeIndexer indexer;
-
-  @Inject protected Provider<InternalChangeQuery> queryProvider;
-
-  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
-
-  @Inject @GerritServerConfig protected Config cfg;
-
-  @Inject private InProcessProtocol inProcessProtocol;
-
-  @Inject private Provider<AnonymousUser> anonymousUser;
-
-  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
-
-  @Inject protected ChangeData.Factory changeDataFactory;
-
-  @Inject protected PatchSetUtil psUtil;
-
-  @Inject protected ChangeFinder changeFinder;
-
-  @Inject protected Revisions revisions;
-
-  @Inject protected FakeEmailSender sender;
-
-  @Inject protected ChangeNoteUtil changeNoteUtil;
-
-  @Inject protected ChangeResource.Factory changeResourceFactory;
-
-  @Inject protected SystemGroupBackend systemGroupBackend;
-
-  @Inject private EventRecorder.Factory eventRecorderFactory;
-
-  @Inject private ChangeIndexCollection changeIndexes;
-
-  protected TestRepository<InMemoryRepository> testRepo;
-  protected GerritServer server;
-  protected TestAccount admin;
-  protected TestAccount user;
-  protected RestSession adminRestSession;
-  protected RestSession userRestSession;
-  protected SshSession adminSshSession;
-  protected SshSession userSshSession;
-  protected ReviewDb db;
-  protected Project.NameKey project;
-  protected EventRecorder eventRecorder;
-
-  @Inject protected TestNotesMigration notesMigration;
-
-  @Inject protected ChangeNotes.Factory notesFactory;
-
-  @Inject protected Abandon changeAbandoner;
+  @Rule public ExpectedException exception = ExpectedException.none();
 
   protected final TemporaryFolder tempSiteDir = new TemporaryFolder();
 
@@ -263,14 +189,57 @@
         }
       };
 
-  @Rule
-  public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
+  @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
+  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
+  @Inject @GerritServerConfig protected Config cfg;
+  @Inject protected AcceptanceTestRequestScope atrScope;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountCreator accounts;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected BatchUpdate.Factory batchUpdateFactory;
+  @Inject protected ChangeData.Factory changeDataFactory;
+  @Inject protected ChangeFinder changeFinder;
+  @Inject protected ChangeIndexer indexer;
+  @Inject protected ChangeNoteUtil changeNoteUtil;
+  @Inject protected ChangeResource.Factory changeResourceFactory;
+  @Inject protected FakeEmailSender sender;
+  @Inject protected GerritApi gApi;
+  @Inject protected GitRepositoryManager repoManager;
+  @Inject protected GroupCache groupCache;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected PatchSetUtil psUtil;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected PushOneCommit.Factory pushFactory;
+  @Inject protected Revisions revisions;
+  @Inject protected SystemGroupBackend systemGroupBackend;
+  @Inject protected TestNotesMigration notesMigration;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected Abandon changeAbandoner;
 
-  private String resourcePrefix;
+  protected EventRecorder eventRecorder;
+  protected GerritServer server;
+  protected Project.NameKey project;
+  protected RestSession adminRestSession;
+  protected RestSession userRestSession;
+  protected ReviewDb db;
+  protected SshSession adminSshSession;
+  protected SshSession userSshSession;
+  protected TestAccount admin;
+  protected TestAccount user;
+  protected TestRepository<InMemoryRepository> testRepo;
+
+  @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private EventRecorder.Factory eventRecorderFactory;
+  @Inject private InProcessProtocol inProcessProtocol;
+  @Inject private Provider<AnonymousUser> anonymousUser;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+
   private List<Repository> toClose;
+  private String resourcePrefix;
   private boolean useSsh;
 
   @Before
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d790365..41b8f02 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -145,8 +145,6 @@
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
   @Before
@@ -2784,7 +2782,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 359883a..4111a3d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -114,8 +114,6 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private RegistrationHandle onSubmitValidatorHandle;
 
@@ -807,7 +805,7 @@
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          updateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index 26e6847..c2822ce 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
@@ -58,8 +57,6 @@
     return allowDraftsDisabledConfig();
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Test
   public void deleteDraftChange() throws Exception {
     assume().that(isAllowDrafts()).isTrue();
@@ -244,7 +241,7 @@
 
   private void markChangeAsDraft(Change.Id id) throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new MarkChangeAsDraftUpdateOp());
       batchUpdate.execute();
     }
@@ -256,7 +253,7 @@
   private void setDraftStatusOfPatchSetsOfChange(Change.Id id, boolean draftStatus)
       throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new DraftStatusOfPatchSetsUpdateOp(draftStatus));
       batchUpdate.execute();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 329716f..8f256be 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -79,8 +79,6 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangeInserter.Factory changeInserterFactory;
 
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
@@ -784,7 +782,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return updateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
   private ChangeControl insertChange() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index fcbad4f..e957c88 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -64,8 +64,6 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangesCollection changes;
 
   @Test
@@ -578,7 +576,7 @@
   }
 
   private void clearGroups(final PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = updateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
           psId.getParentKey(),
           new BatchUpdateOp() {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 15b74bd..9560a44 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -139,8 +139,6 @@
 
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
 
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
-
   @Inject private Sequences seq;
 
   @Inject private ChangeBundleReader bundleReader;
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
index 4a5d496..2c61e64 100644
--- 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
@@ -20,30 +20,45 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 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.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateListener;
 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.server.update.RetryHelper;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
 public class NoteDbOnlyIT extends AbstractDaemonTest {
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private RetryHelper retryHelper;
 
   @Before
   public void setUp() throws Exception {
@@ -81,7 +96,7 @@
           }
         };
 
-    try (BatchUpdate bu = newBatchUpdate()) {
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
       bu.addOp(id, backupMasterOp);
       bu.execute();
     }
@@ -97,7 +112,7 @@
     assertThat(master2).isNotEqualTo(master1);
     int msgCount = getMessages(id).size();
 
-    try (BatchUpdate bu = newBatchUpdate()) {
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
       // This time, we attempt to back up master, but we fail during updateChange.
       bu.addOp(id, backupMasterOp);
       String msg = "Change is bad";
@@ -122,9 +137,175 @@
     assertThat(getMessages(id)).hasSize(msgCount);
   }
 
-  private BatchUpdate newBatchUpdate() {
-    return batchUpdateFactory.create(
-        db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  @Test
+  public void retryOnLockFailureWithAtomicUpdates() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    String result =
+        retryHelper.execute(
+            batchUpdateFactory -> {
+              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                bu.addOp(
+                    id,
+                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+              }
+              return "Done";
+            });
+
+    assertThat(result).isEqualTo("Done");
+    assertThat(updateRepoCalledCount.get()).isEqualTo(2);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(2);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(2);
+
+    List<String> messages = getMessages(id);
+    assertThat(Iterables.getLast(messages)).isEqualTo(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+    assertThat(Collections.frequency(messages, UpdateRefAndAddMessageOp.CHANGE_MESSAGE))
+        .isEqualTo(1);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Then op retried and wrote
+      // its commit with the other writer's commit as parent.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(
+              ConcurrentWritingListener.MSG_PREFIX + "1", UpdateRefAndAddMessageOp.COMMIT_MESSAGE)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void noRetryOnLockFailureWithoutAtomicUpdates() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    try {
+      retryHelper.execute(
+          batchUpdateFactory -> {
+            try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+              bu.addOp(
+                  id, new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+              bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+            }
+            return null;
+          });
+      assert_().fail("expected RestApiException");
+    } catch (RestApiException e) {
+      // Expected.
+    }
+
+    assertThat(updateRepoCalledCount.get()).isEqualTo(1);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(1);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(0);
+
+    // updateChange was never called, so no message was ever added.
+    assertThat(getMessages(id)).doesNotContain(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Op didn't retry, because the
+      // ref updates weren't atomic, so it didn't throw LockFailureException on failure.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(ConcurrentWritingListener.MSG_PREFIX + "1");
+    }
+  }
+
+  private class ConcurrentWritingListener implements BatchUpdateListener {
+    static final String MSG_PREFIX = "Other writer ";
+
+    private final AtomicInteger calledCount;
+
+    private ConcurrentWritingListener(AtomicInteger calledCount) {
+      this.calledCount = calledCount;
+    }
+
+    @Override
+    public void afterUpdateRepos() throws Exception {
+      // Reopen repo and update ref, to simulate a concurrent write in another
+      // thread. Only do this the first time the listener is called.
+      if (calledCount.getAndIncrement() > 0) {
+        return;
+      }
+      try (Repository repo = repoManager.openRepository(project);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        String master = "refs/heads/master";
+        ObjectId oldId = repo.exactRef(master).getObjectId();
+        ObjectId newId = newCommit(rw, ins, oldId, MSG_PREFIX + calledCount.get());
+        ins.flush();
+        RefUpdate ru = repo.updateRef(master);
+        ru.setExpectedOldObjectId(oldId);
+        ru.setNewObjectId(newId);
+        assertThat(ru.update(rw)).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+      }
+    }
+  }
+
+  private class UpdateRefAndAddMessageOp implements BatchUpdateOp {
+    static final String COMMIT_MESSAGE = "A commit";
+    static final String CHANGE_MESSAGE = "A change message";
+
+    private final AtomicInteger updateRepoCalledCount;
+    private final AtomicInteger updateChangeCalledCount;
+
+    private UpdateRefAndAddMessageOp(
+        AtomicInteger updateRepoCalledCount, AtomicInteger updateChangeCalledCount) {
+      this.updateRepoCalledCount = updateRepoCalledCount;
+      this.updateChangeCalledCount = updateChangeCalledCount;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      String master = "refs/heads/master";
+      ObjectId oldId = ctx.getRepoView().getRef(master).get();
+      ObjectId newId = newCommit(ctx.getRevWalk(), ctx.getInserter(), oldId, COMMIT_MESSAGE);
+      ctx.addRefUpdate(oldId, newId, master);
+      updateRepoCalledCount.incrementAndGet();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(CHANGE_MESSAGE);
+      updateChangeCalledCount.incrementAndGet();
+      return true;
+    }
+  }
+
+  private ObjectId newCommit(RevWalk rw, ObjectInserter ins, ObjectId parent, String msg)
+      throws IOException {
+    PersonIdent ident = serverIdent.get();
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parent);
+    cb.setTreeId(rw.parseCommit(parent).getTree());
+    cb.setMessage(msg);
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    return ins.insert(Constants.OBJ_COMMIT, cb.build());
+  }
+
+  private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
+    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
   }
 
   private Optional<ObjectId> getRef(String name) throws Exception {
@@ -142,4 +323,15 @@
         .map(m -> m.message)
         .collect(toList());
   }
+
+  private static List<String> commitMessages(
+      Repository repo, ObjectId fromExclusive, ObjectId toInclusive) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.markStart(rw.parseCommit(toInclusive));
+      rw.markUninteresting(rw.parseCommit(fromExclusive));
+      rw.sort(RevSort.REVERSE);
+      rw.setRetainBody(true);
+      return Streams.stream(rw).map(c -> c.getShortMessage()).collect(toList());
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
index dd2e8af..d628268 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -62,7 +62,6 @@
 import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
@@ -95,7 +94,6 @@
   }
 
   @Inject private AllUsersName allUsers;
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
   @Inject private ChangeBundleReader bundleReader;
   @Inject private CommentsUtil commentsUtil;
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
index 22329fd..1d420bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -107,6 +107,7 @@
     private final FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory;
     private final UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory;
 
+    // TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
     @Inject
     Factory(
         NotesMigration migration,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
index 39e25dd..87a43a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
@@ -22,8 +22,11 @@
  * BatchUpdate#addOp(com.google.gerrit.reviewdb.client.Change.Id, BatchUpdateOp)}.
  *
  * <p>Usually, a single {@code BatchUpdateOp} instance is only associated with a single change, i.e.
- * {@code addOp} is only called once with that instance. This allows an instance to communicate
- * between phases by storing data in private fields.
+ * {@code addOp} is only called once with that instance. Additionally, each method in {@code
+ * BatchUpdateOp} is called at most once per {@link BatchUpdate} execution.
+ *
+ * <p>Taken together, these two properties mean an instance may communicate between phases by
+ * storing data in private fields, and a single instance must not be reused.
  */
 public interface BatchUpdateOp extends RepoOnlyOp {
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
new file mode 100644
index 0000000..dbbce2b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
@@ -0,0 +1,79 @@
+// 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.server.update;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class RetryHelper {
+  public interface Action<T> {
+    T call(BatchUpdate.Factory updateFactory) throws Exception;
+  }
+
+  private final BatchUpdate.Factory updateFactory;
+
+  @Inject
+  RetryHelper(
+      NotesMigration migration,
+      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+      FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory,
+      UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory) {
+    this.updateFactory =
+        new BatchUpdate.Factory(
+            migration,
+            reviewDbBatchUpdateFactory,
+            fusedNoteDbBatchUpdateFactory,
+            unfusedNoteDbBatchUpdateFactory);
+  }
+
+  public <T> T execute(Action<T> action) throws RestApiException, UpdateException {
+    try {
+      // TODO(dborowitz): Make configurable.
+      return RetryerBuilder.<T>newBuilder()
+          .withStopStrategy(StopStrategies.stopAfterDelay(20, TimeUnit.SECONDS))
+          .withWaitStrategy(
+              WaitStrategies.join(
+                  WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
+                  WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+          .retryIfException(RetryHelper::isLockFailure)
+          .build()
+          .call(() -> action.call(updateFactory));
+    } catch (ExecutionException | RetryException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+      }
+      throw new UpdateException(e);
+    }
+  }
+
+  private static boolean isLockFailure(Throwable t) {
+    if (t instanceof UpdateException) {
+      t = t.getCause();
+    }
+    return t instanceof LockFailureException;
+  }
+}