com.google.gerrit.server.notedb.DeleteZombieCommentsRefs Maven / Gradle / Ivy
// 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.notedb;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.DeleteZombieComments;
import com.google.gerrit.server.DraftCommentsReader;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ReceiveCommand;
/**
 * This class can be used to clean zombie draft comments from NoteDB.
 *
 * See {@link DeleteZombieComments} for more info.
 */
public class DeleteZombieCommentsRefs extends DeleteZombieComments {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  // Number of refs deleted at once in a batch ref-update.
  // Log progress after deleting every CHUNK_SIZE refs
  private static final int CHUNK_SIZE = 3000;
  private final GitRepositoryManager repoManager;
  private final AllUsersName allUsers;
  @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
  @Nullable private final IdentifiedUser.GenericFactory userFactory;
  private Repository allUsersRepo;
  public interface Factory {
    DeleteZombieCommentsRefs create(int cleanupPercentage);
    DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
  }
  @AssistedInject
  public DeleteZombieCommentsRefs(
      @Assisted Integer cleanupPercentage,
      GitRepositoryManager repoManager,
      AllUsersName allUsers,
      DraftCommentsReader draftCommentsReader,
      ChangeNotes.Factory changeNotesFactory,
      CommentsUtil commentsUtil,
      ChangeUpdate.Factory changeUpdateFactory,
      IdentifiedUser.GenericFactory userFactory) {
    this(
        cleanupPercentage,
        /* dryRun= */ false,
        (msg) -> {},
        repoManager,
        allUsers,
        draftCommentsReader,
        changeNotesFactory,
        commentsUtil,
        changeUpdateFactory,
        userFactory);
  }
  @AssistedInject
  public DeleteZombieCommentsRefs(
      @Assisted Integer cleanupPercentage,
      @Assisted boolean dryRun,
      GitRepositoryManager repoManager,
      AllUsersName allUsers,
      DraftCommentsReader draftCommentsReader,
      ChangeNotes.Factory changeNotesFactory,
      CommentsUtil commentsUtil,
      ChangeUpdate.Factory changeUpdateFactory,
      IdentifiedUser.GenericFactory userFactory) {
    this(
        cleanupPercentage,
        dryRun,
        (msg) -> {},
        repoManager,
        allUsers,
        draftCommentsReader,
        changeNotesFactory,
        commentsUtil,
        changeUpdateFactory,
        userFactory);
  }
  public DeleteZombieCommentsRefs(
      AllUsersName allUsers,
      GitRepositoryManager repoManager,
      Integer cleanupPercentage,
      Consumer uiConsumer) {
    this(
        cleanupPercentage,
        /* dryRun= */ false,
        uiConsumer,
        repoManager,
        allUsers,
        null,
        null,
        null,
        null,
        null);
  }
  private DeleteZombieCommentsRefs(
      Integer cleanupPercentage,
      boolean dryRun,
      Consumer uiConsumer,
      GitRepositoryManager repoManager,
      AllUsersName allUsers,
      @Nullable DraftCommentsReader draftCommentsReader,
      @Nullable ChangeNotes.Factory changeNotesFactory,
      @Nullable CommentsUtil commentsUtil,
      @Nullable ChangeUpdate.Factory changeUpdateFactory,
      @Nullable IdentifiedUser.GenericFactory userFactory) {
    super(
        cleanupPercentage,
        dryRun,
        uiConsumer,
        repoManager,
        draftCommentsReader,
        changeNotesFactory,
        commentsUtil);
    this.allUsers = allUsers;
    this.repoManager = repoManager;
    this.changeUpdateFactory = changeUpdateFactory;
    this.userFactory = userFactory;
  }
  @Override
  public void setup() throws IOException {
    allUsersRepo = repoManager.openRepository(allUsers);
  }
  @Override
  public void close() throws IOException {
    allUsersRepo.close();
  }
  @Override
  protected List listAllDrafts() throws IOException {
    return allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
  }
  @Override
  protected List listEmptyDrafts() throws IOException {
    List zombieRefs = filterZombieRefs(allUsersRepo, listAllDrafts());
    logInfo(
        String.format(
            "Found a total of %d zombie draft refs in %s repo.",
            zombieRefs.size(), allUsers.get()));
    return zombieRefs;
  }
  /**
   * An earlier bug in the deletion of draft comments {@code
   * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in
   * Git and not get deleted. These refs point to an empty tree. We delete such refs.
   */
  @Override
  protected void deleteEmptyDraftsByKey(Collection refs) throws IOException {
    long zombieRefsCnt = refs.size();
    long deletedRefsCnt = 0;
    long startTime = System.currentTimeMillis();
    for (List refsBatch : Iterables.partition(refs, CHUNK_SIZE)) {
      deleteZombieDraftsBatch(refsBatch);
      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
      deletedRefsCnt += refsBatch.size();
      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
    }
  }
  @Override
  protected void deleteZombieDrafts(ListMultimap drafts) throws IOException {
    for (Map.Entry> e : drafts.asMap().entrySet()) {
      deleteZombieDraftsForChange(
          getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
    }
  }
  @Override
  protected Change.Id getChangeId(Ref ref) {
    return Change.Id.fromAllUsersRef(ref.getName());
  }
  @Override
  protected Account.Id getAccountId(Ref ref) {
    return Account.Id.fromRef(ref.getName());
  }
  @Override
  protected String loggable(Ref ref) {
    return ref.getName();
  }
  private List filterZombieRefs(Repository allUsersRepo, List allDraftRefs)
      throws IOException {
    List zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
    for (Ref ref : allDraftRefs) {
      if (isZombieRef(allUsersRepo, ref)) {
        zombieRefs.add(ref);
      }
    }
    return zombieRefs;
  }
  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
  }
  private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
    logInfo(
        String.format(
            "Deleted %d/%d zombie draft refs (%d seconds)",
            deletedRefsCount, allRefsCount, elapsed));
  }
  private void deleteZombieDraftsBatch(Collection refsBatch) throws IOException {
    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
      ImmutableList deleteCommands =
          refsBatch.stream()
              .map(
                  zombieRef ->
                      new ReceiveCommand(
                          zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
              .collect(toImmutableList());
      BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
      bru.setAtomic(true);
      bru.addCommand(deleteCommands);
      RefUpdateUtil.executeChecked(bru, allUsersRepo);
    }
  }
  /**
   * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
   * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
   * draft.
   */
  private void deleteZombieDraftsForChange(
      Account.Id accountId, ChangeNotes changeNotes, Collection draftsToDelete)
      throws IOException {
    if (changeUpdateFactory == null || userFactory == null) {
      return;
    }
    ChangeUpdate changeUpdate =
        changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
    draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
    changeUpdate.commit();
    logger.atInfo().log(
        "Deleted zombie draft comments with UUIDs %s",
        draftsToDelete.stream().map(d -> d.key.uuid).collect(toImmutableList()));
  }
}