Merge "Merge branch 'stable-3.4' into stable-3.5" into stable-3.5
diff --git a/java/com/google/gerrit/pgm/CopyApprovals.java b/java/com/google/gerrit/pgm/CopyApprovals.java
index 73b0da6..f62d60c 100644
--- a/java/com/google/gerrit/pgm/CopyApprovals.java
+++ b/java/com/google/gerrit/pgm/CopyApprovals.java
@@ -72,7 +72,7 @@
     sysInjector.injectMembers(this);
 
     try {
-      recursiveApprovalCopier.persist();
+      recursiveApprovalCopier.persistStandalone();
       return 0;
     } catch (Exception e) {
       throw die(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
index 0926daf..24595674 100644
--- a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
@@ -16,13 +16,23 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.InternalUser;
 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.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,50 +41,219 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.RefDirectory;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class RecursiveApprovalCopier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int SLICE_MAX_REFS = 1000;
 
   private final BatchUpdate.Factory batchUpdateFactory;
   private final GitRepositoryManager repositoryManager;
   private final InternalUser.Factory internalUserFactory;
   private final ApprovalsUtil approvalsUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ListeningExecutorService executor;
+
+  private final ConcurrentHashMap<Project.NameKey, List<ReceiveCommand>> pendingRefUpdates =
+      new ConcurrentHashMap<>();
+
+  private volatile boolean failedForAtLeastOneProject;
+
+  private final AtomicInteger totalCopyApprovalsTasks = new AtomicInteger();
+  private final AtomicInteger finishedCopyApprovalsTasks = new AtomicInteger();
+
+  private final AtomicInteger totalRefUpdates = new AtomicInteger();
+  private final AtomicInteger finishedRefUpdates = new AtomicInteger();
 
   @Inject
   public RecursiveApprovalCopier(
       BatchUpdate.Factory batchUpdateFactory,
       GitRepositoryManager repositoryManager,
       InternalUser.Factory internalUserFactory,
-      ApprovalsUtil approvalsUtil) {
+      ApprovalsUtil approvalsUtil,
+      ChangeNotes.Factory changeNotesFactory,
+      @FanOutExecutor ExecutorService executor) {
     this.batchUpdateFactory = batchUpdateFactory;
     this.repositoryManager = repositoryManager;
     this.internalUserFactory = internalUserFactory;
     this.approvalsUtil = approvalsUtil;
+    this.changeNotesFactory = changeNotesFactory;
+    this.executor = MoreExecutors.listeningDecorator(executor);
   }
 
-  public void persist()
-      throws UpdateException, RestApiException, RepositoryNotFoundException, IOException {
-    for (Project.NameKey project : repositoryManager.list()) {
-      persist(project, null);
+  /**
+   * This method assumes it is used as a standalone program having exclusive access to the Git
+   * repositories. Therefore, it will (safely) skip locking of the loose refs when performing batch
+   * ref-updates.
+   */
+  public void persistStandalone()
+      throws RepositoryNotFoundException, IOException, InterruptedException, ExecutionException {
+    persist(repositoryManager.list(), null, false);
+
+    if (failedForAtLeastOneProject) {
+      throw new RuntimeException("There were errors, check the logs for details");
     }
   }
 
   public void persist(Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
-      throws IOException, UpdateException, RestApiException, RepositoryNotFoundException {
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs());
-        Repository repository = repositoryManager.openRepository(project)) {
-      for (Ref changeMetaRef :
+      throws IOException, RepositoryNotFoundException, InterruptedException, ExecutionException {
+    persist(ImmutableList.of(project), labelsCopiedListener, true);
+  }
+
+  private void persist(
+      Collection<Project.NameKey> projects,
+      @Nullable Consumer<Change> labelsCopiedListener,
+      boolean shouldLockLooseRefs)
+      throws InterruptedException, ExecutionException, RepositoryNotFoundException, IOException {
+    List<ListenableFuture<Void>> copyApprovalsTasks = new LinkedList<>();
+    for (Project.NameKey project : projects) {
+      copyApprovalsTasks.addAll(submitCopyApprovalsTasks(project, labelsCopiedListener));
+    }
+    Futures.successfulAsList(copyApprovalsTasks).get();
+
+    List<ListenableFuture<Void>> batchRefUpdateTasks =
+        submitBatchRefUpdateTasks(shouldLockLooseRefs);
+    Futures.successfulAsList(batchRefUpdateTasks).get();
+  }
+
+  private List<ListenableFuture<Void>> submitCopyApprovalsTasks(
+      Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
+      throws RepositoryNotFoundException, IOException {
+    List<ListenableFuture<Void>> futures = new LinkedList<>();
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      ImmutableList<Ref> allMetaRefs =
           repository.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
               .filter(r -> r.getName().endsWith(RefNames.META_SUFFIX))
-              .collect(toImmutableList())) {
-        Change.Id changeId = Change.Id.fromRef(changeMetaRef.getName());
-        bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil, labelsCopiedListener));
+              .collect(toImmutableList());
+
+      totalCopyApprovalsTasks.addAndGet(allMetaRefs.size());
+
+      for (List<Ref> slice : Lists.partition(allMetaRefs, SLICE_MAX_REFS)) {
+        futures.add(
+            executor.submit(
+                () -> {
+                  copyApprovalsForSlice(project, slice, labelsCopiedListener);
+                  return null;
+                }));
       }
-      bu.execute();
+    }
+    return futures;
+  }
+
+  private void copyApprovalsForSlice(
+      Project.NameKey project, List<Ref> slice, @Nullable Consumer<Change> labelsCopiedListener)
+      throws Exception {
+    try {
+      copyApprovalsForSlice(project, slice, labelsCopiedListener, false);
+    } catch (Exception e) {
+      failedForAtLeastOneProject = true;
+      logger.atSevere().withCause(e).log(
+          "Error in a slice of project %s, will retry and skip corrupt meta-refs", project);
+      copyApprovalsForSlice(project, slice, labelsCopiedListener, true);
+    }
+    logProgress();
+  }
+
+  private void copyApprovalsForSlice(
+      Project.NameKey project,
+      List<Ref> slice,
+      @Nullable Consumer<Change> labelsCopiedListener,
+      boolean checkForCorruptMetaRefs)
+      throws Exception {
+    logger.atInfo().log("copy-approvals for a slice of %s project", project);
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
+      for (Ref metaRef : slice) {
+        Change.Id changeId = Change.Id.fromRef(metaRef.getName());
+        if (checkForCorruptMetaRefs && isCorrupt(project, changeId)) {
+          logger.atSevere().log("skipping corrupt meta-ref %s", metaRef.getName());
+          continue;
+        }
+        bu.addOp(
+            changeId,
+            new RecursiveApprovalCopier.PersistCopiedVotesOp(approvalsUtil, labelsCopiedListener));
+      }
+
+      BatchRefUpdate bru = bu.prepareRefUpdates();
+      if (bru != null) {
+        List<ReceiveCommand> cmds = bru.getCommands();
+        pendingRefUpdates.compute(
+            project,
+            (p, u) -> {
+              if (u == null) {
+                return new LinkedList<>(cmds);
+              }
+              u.addAll(cmds);
+              return u;
+            });
+        totalRefUpdates.addAndGet(cmds.size());
+      }
+
+      finishedCopyApprovalsTasks.addAndGet(slice.size());
+    }
+  }
+
+  private List<ListenableFuture<Void>> submitBatchRefUpdateTasks(boolean shouldLockLooseRefs) {
+    logger.atInfo().log("submitting batch ref-update tasks");
+    List<ListenableFuture<Void>> futures = new LinkedList<>();
+    for (Map.Entry<Project.NameKey, List<ReceiveCommand>> e : pendingRefUpdates.entrySet()) {
+      Project.NameKey project = e.getKey();
+      List<ReceiveCommand> updates = e.getValue();
+      futures.add(
+          executor.submit(
+              () -> {
+                executeRefUpdates(project, updates, shouldLockLooseRefs);
+                return null;
+              }));
+    }
+    return futures;
+  }
+
+  private void executeRefUpdates(
+      Project.NameKey project, List<ReceiveCommand> updates, boolean shouldLockLooseRefs)
+      throws RepositoryNotFoundException, IOException {
+    logger.atInfo().log(
+        "executing batch ref-update for project %s, size %d", project, updates.size());
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      RefDatabase refdb = repository.getRefDatabase();
+      BatchRefUpdate bu;
+      if (refdb instanceof RefDirectory) {
+        bu = ((RefDirectory) refdb).newBatchUpdate(shouldLockLooseRefs);
+      } else {
+        bu = refdb.newBatchUpdate();
+      }
+      bu.addCommand(updates);
+      RefUpdateUtil.executeChecked(bu, repository);
+
+      finishedRefUpdates.addAndGet(updates.size());
+      logProgress();
+    }
+  }
+
+  private boolean isCorrupt(Project.NameKey project, Change.Id changeId) {
+    Change c = ChangeNotes.Factory.newChange(project, changeId);
+    try {
+      changeNotesFactory.createForBatchUpdate(c, true);
+      return false;
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log(e.getMessage());
+      return true;
     }
   }
 
@@ -88,6 +267,15 @@
     }
   }
 
+  private void logProgress() {
+    logger.atInfo().log(
+        "copy-approvals tasks done: %d/%d, ref-update tasks done: %d/%d",
+        finishedCopyApprovalsTasks.get(),
+        totalCopyApprovalsTasks.get(),
+        finishedRefUpdates.get(),
+        totalRefUpdates.get());
+  }
+
   private static class PersistCopiedVotesOp implements BatchUpdateOp {
     private final ApprovalsUtil approvalsUtil;
     private final Consumer<Change> listener;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 9345d98..e817fe6 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -306,6 +306,12 @@
     }
   }
 
+  public BatchRefUpdate prepare() throws IOException {
+    checkNotExecuted();
+    stage();
+    return prepare(changeRepo, false, pushCert);
+  }
+
   @Nullable
   public BatchRefUpdate execute() throws IOException {
     return execute(false);
@@ -353,7 +359,7 @@
     }
   }
 
-  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
+  private BatchRefUpdate prepare(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
       return null;
@@ -382,7 +388,13 @@
       bru = listener.beforeUpdateRefs(bru);
     }
 
-    if (!dryrun) {
+    return bru;
+  }
+
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
+      throws IOException {
+    BatchRefUpdate bru = prepare(or, dryrun, pushCert);
+    if (bru != null && !dryrun) {
       RefUpdateUtil.executeChecked(bru, or.rw);
     }
     return bru;
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 232aa98..b88d0c0 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -444,6 +444,11 @@
     execute(ImmutableList.of(this), ImmutableList.of(), false);
   }
 
+  public BatchRefUpdate prepareRefUpdates() throws Exception {
+    ChangesHandle handle = executeChangeOps(ImmutableList.of(), false);
+    return handle.prepare();
+  }
+
   public boolean isExecuted() {
     return executed;
   }
@@ -620,6 +625,10 @@
       checkArgument(old == null, "result for change %s already set: %s", id, old);
     }
 
+    public BatchRefUpdate prepare() throws IOException {
+      return manager.prepare();
+    }
+
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
       BatchUpdate.this.executed = manager.isExecuted();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
index 90ee13e..c2f7771 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
@@ -25,15 +26,20 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.server.approval.RecursiveApprovalCopier;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.junit.Test;
 
 public class CopyApprovalsIT extends AbstractDaemonTest {
@@ -42,8 +48,8 @@
 
   @Test
   public void multipleProjects() throws Exception {
-    projectOperations.newProject().name("secondProject").create();
-    TestRepository<InMemoryRepository> secondRepo = cloneProject(project, admin);
+    Project.NameKey secondProject = projectOperations.newProject().name("secondProject").create();
+    TestRepository<InMemoryRepository> secondRepo = cloneProject(secondProject, admin);
 
     PushOneCommit.Result change1 = createChange();
     gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
@@ -66,12 +72,11 @@
     // change the project config to make the vote that was not copied to be copied once we do the
     // schema upgrade.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(/* copyAnyScore= */ true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
-    recursiveApprovalCopier.persist();
+    recursiveApprovalCopier.persistStandalone();
 
     ApprovalInfo vote1 =
         Iterables.getOnlyElement(
@@ -87,11 +92,70 @@
   }
 
   @Test
+  public void corruptChangeInOneProject_OtherProjectsProcessed() throws Exception {
+    Project.NameKey corruptProject = projectOperations.newProject().name("corruptProject").create();
+    TestRepository<InMemoryRepository> corruptRepo = cloneProject(corruptProject, admin);
+
+    PushOneCommit.Result change1 = createChange();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
+    PushOneCommit.Result change2 = createChange(corruptRepo);
+    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.dislike());
+
+    // these amends are reworks so votes will not be copied.
+    amendChange(change1.getChangeId());
+    amendChange(change2.getChangeId(), "refs/for/master", admin, corruptRepo);
+
+    // votes don't exist on the new patch-set.
+    assertThat(gApi.changes().id(change1.getChangeId()).current().votes()).isEmpty();
+    assertThat(gApi.changes().id(change2.getChangeId()).current().votes()).isEmpty();
+
+    // change the project config to make the vote that was not copied to be copied once we do the
+    // schema upgrade.
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
+      u.save();
+    }
+
+    // make the meta-ref of change2 corrupt by updating it to the commit of the current patch-set
+    ObjectId correctMetaRefObjectId;
+    String metaRef = RefNames.changeMetaRef(change2.getChange().getId());
+    try (TestRepository<InMemoryRepository> serverSideCorruptRepo =
+        new TestRepository<>((InMemoryRepository) repoManager.openRepository(corruptProject))) {
+      RefUpdate ru = forceUpdate(serverSideCorruptRepo, metaRef, change2.getPatchSet().commitId());
+      correctMetaRefObjectId = ru.getOldObjectId();
+
+      try {
+        recursiveApprovalCopier.persistStandalone();
+        fail("Expected exception when a project contains corrupt change");
+      } catch (Exception e) {
+        assertThat(e.getMessage()).contains("check the logs");
+      } finally {
+        // fix the meta-ref by setting it back to its correct objectId
+        forceUpdate(serverSideCorruptRepo, metaRef, correctMetaRefObjectId);
+      }
+    }
+
+    ApprovalInfo vote1 =
+        Iterables.getOnlyElement(
+            gApi.changes().id(change1.getChangeId()).current().votes().values());
+    assertThat(vote1.value).isEqualTo(1);
+    assertThat(vote1._accountId).isEqualTo(admin.id().get());
+  }
+
+  private RefUpdate forceUpdate(
+      TestRepository<InMemoryRepository> repo, String ref, ObjectId newObjectId)
+      throws IOException {
+    RefUpdate ru = repo.getRepository().updateRef(ref);
+    ru.setNewObjectId(newObjectId);
+    ru.forceUpdate();
+    return ru;
+  }
+
+  @Test
   public void changeWithPersistedVotesNotHarmed() throws Exception {
     // change the project config to copy all votes
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(/* copyAnyScore= */ true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -142,8 +206,7 @@
     // change the project config to make the vote that was not copied to be copied once we do the
     // schema upgrade.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(/* copyAnyScore= */ true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -157,4 +220,38 @@
       assertThat(vote1._accountId).isEqualTo(admin.id().get());
     }
   }
+
+  @Test
+  public void oneCorruptChange_otherChangesProcessed() throws Exception {
+    PushOneCommit.Result good = createChange();
+    gApi.changes().id(good.getChangeId()).current().review(ReviewInput.recommend());
+    // this amend is a rework so votes will not be copied.
+    amendChange(good.getChangeId());
+
+    PushOneCommit.Result corrupt = createChange();
+
+    // change the project config to make the vote that was not copied to be copied once we do the
+    // schema upgrade.
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
+      u.save();
+    }
+
+    // make the meta-ref corrupt by updating it to the commit of the current patch-set
+    String metaRef = RefNames.changeMetaRef(corrupt.getChange().getId());
+    try (TestRepository<InMemoryRepository> serverSideTestRepo =
+        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project))) {
+      RefUpdate ru = forceUpdate(serverSideTestRepo, metaRef, corrupt.getPatchSet().commitId());
+      try {
+        recursiveApprovalCopier.persist(project, null);
+      } finally {
+        forceUpdate(serverSideTestRepo, metaRef, ru.getOldObjectId());
+      }
+    }
+
+    ApprovalInfo vote1 =
+        Iterables.getOnlyElement(gApi.changes().id(good.getChangeId()).current().votes().values());
+    assertThat(vote1.value).isEqualTo(1);
+    assertThat(vote1._accountId).isEqualTo(admin.id().get());
+  }
 }
diff --git a/modules/jgit b/modules/jgit
index d013761..cb90ed0 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit d01376106af8800017ac3c08d7c7ac1fd5ccc9ee
+Subproject commit cb90ed08526bd51f04e5d72e3ba3cf5bd30c11e4