LCOV - code coverage report
Current view: top level - server/notedb - DeleteZombieCommentsRefs.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 146 167 87.4 %
Date: 2022-11-19 15:00:39 Functions: 28 31 90.3 %

          Line data    Source code
       1             : // Copyright (C) 2020 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.notedb;
      16             : 
      17             : import static com.google.common.collect.ImmutableList.toImmutableList;
      18             : import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
      19             : import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
      20             : 
      21             : import com.google.auto.value.AutoValue;
      22             : import com.google.common.annotations.VisibleForTesting;
      23             : import com.google.common.collect.ImmutableSet;
      24             : import com.google.common.collect.Iterables;
      25             : import com.google.common.collect.Sets;
      26             : import com.google.common.collect.Sets.SetView;
      27             : import com.google.common.flogger.FluentLogger;
      28             : import com.google.gerrit.common.Nullable;
      29             : import com.google.gerrit.entities.Account;
      30             : import com.google.gerrit.entities.Change;
      31             : import com.google.gerrit.entities.HumanComment;
      32             : import com.google.gerrit.entities.Project;
      33             : import com.google.gerrit.entities.RefNames;
      34             : import com.google.gerrit.git.RefUpdateUtil;
      35             : import com.google.gerrit.server.CommentsUtil;
      36             : import com.google.gerrit.server.IdentifiedUser;
      37             : import com.google.gerrit.server.config.AllUsersName;
      38             : import com.google.gerrit.server.git.GitRepositoryManager;
      39             : import com.google.gerrit.server.util.time.TimeUtil;
      40             : import com.google.inject.assistedinject.Assisted;
      41             : import com.google.inject.assistedinject.AssistedInject;
      42             : import java.io.IOException;
      43             : import java.sql.Timestamp;
      44             : import java.util.ArrayList;
      45             : import java.util.HashMap;
      46             : import java.util.HashSet;
      47             : import java.util.List;
      48             : import java.util.Map;
      49             : import java.util.Set;
      50             : import java.util.function.Consumer;
      51             : import java.util.stream.Collectors;
      52             : import org.eclipse.jgit.lib.BatchRefUpdate;
      53             : import org.eclipse.jgit.lib.ObjectId;
      54             : import org.eclipse.jgit.lib.Ref;
      55             : import org.eclipse.jgit.lib.Repository;
      56             : import org.eclipse.jgit.transport.ReceiveCommand;
      57             : 
      58             : /**
      59             :  * This class can be used to clean zombie draft comments refs. More context in <a
      60             :  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
      61             :  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
      62             :  *
      63             :  * <p>The implementation has two cases for detecting zombie drafts:
      64             :  *
      65             :  * <ul>
      66             :  *   <li>An earlier bug in the deletion of draft comments {@code
      67             :  *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
      68             :  *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
      69             :  *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
      70             :  *       with the same UUID. These comments are called zombie drafts. If the program is run in
      71             :  *       {@link #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
      72             :  *       will also be deleted.
      73             :  * </uL>
      74             :  */
      75             : public class DeleteZombieCommentsRefs {
      76           2 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      77             : 
      78             :   // Number of refs deleted at once in a batch ref-update.
      79             :   // Log progress after deleting every CHUNK_SIZE refs
      80             :   private static final int CHUNK_SIZE = 3000;
      81             : 
      82             :   private final GitRepositoryManager repoManager;
      83             :   private final AllUsersName allUsers;
      84             :   private final int cleanupPercentage;
      85             : 
      86             :   /**
      87             :    * Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
      88             :    * deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
      89             :    * run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
      90             :    * true.
      91             :    */
      92             :   private final boolean dryRun;
      93             : 
      94             :   private final Consumer<String> uiConsumer;
      95             :   @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
      96             :   @Nullable private final ChangeNotes.Factory changeNotesFactory;
      97             :   @Nullable private final CommentsUtil commentsUtil;
      98             :   @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
      99             :   @Nullable private final IdentifiedUser.GenericFactory userFactory;
     100             : 
     101             :   public interface Factory {
     102             :     DeleteZombieCommentsRefs create(int cleanupPercentage);
     103             : 
     104             :     DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
     105             :   }
     106             : 
     107             :   @AssistedInject
     108             :   public DeleteZombieCommentsRefs(
     109             :       AllUsersName allUsers,
     110             :       GitRepositoryManager repoManager,
     111             :       ChangeNotes.Factory changeNotesFactory,
     112             :       DraftCommentNotes.Factory draftNotesFactory,
     113             :       CommentsUtil commentsUtil,
     114             :       ChangeUpdate.Factory changeUpdateFactory,
     115             :       IdentifiedUser.GenericFactory userFactory,
     116             :       @Assisted Integer cleanupPercentage) {
     117           0 :     this(
     118             :         allUsers,
     119             :         repoManager,
     120             :         cleanupPercentage,
     121             :         /* dryRun= */ true,
     122           0 :         (msg) -> {},
     123             :         changeNotesFactory,
     124             :         draftNotesFactory,
     125             :         commentsUtil,
     126             :         changeUpdateFactory,
     127             :         userFactory);
     128           0 :   }
     129             : 
     130             :   @AssistedInject
     131             :   public DeleteZombieCommentsRefs(
     132             :       AllUsersName allUsers,
     133             :       GitRepositoryManager repoManager,
     134             :       ChangeNotes.Factory changeNotesFactory,
     135             :       DraftCommentNotes.Factory draftNotesFactory,
     136             :       CommentsUtil commentsUtil,
     137             :       ChangeUpdate.Factory changeUpdateFactory,
     138             :       IdentifiedUser.GenericFactory userFactory,
     139             :       @Assisted Integer cleanupPercentage,
     140             :       @Assisted boolean dryRun) {
     141           1 :     this(
     142             :         allUsers,
     143             :         repoManager,
     144             :         cleanupPercentage,
     145             :         dryRun,
     146           0 :         (msg) -> {},
     147             :         changeNotesFactory,
     148             :         draftNotesFactory,
     149             :         commentsUtil,
     150             :         changeUpdateFactory,
     151             :         userFactory);
     152           1 :   }
     153             : 
     154             :   public DeleteZombieCommentsRefs(
     155             :       AllUsersName allUsers,
     156             :       GitRepositoryManager repoManager,
     157             :       Integer cleanupPercentage,
     158             :       Consumer<String> uiConsumer) {
     159           1 :     this(
     160             :         allUsers,
     161             :         repoManager,
     162             :         cleanupPercentage,
     163             :         /* dryRun= */ false,
     164             :         uiConsumer,
     165             :         null,
     166             :         null,
     167             :         null,
     168             :         null,
     169             :         null);
     170           1 :   }
     171             : 
     172             :   private DeleteZombieCommentsRefs(
     173             :       AllUsersName allUsers,
     174             :       GitRepositoryManager repoManager,
     175             :       Integer cleanupPercentage,
     176             :       boolean dryRun,
     177             :       Consumer<String> uiConsumer,
     178             :       @Nullable ChangeNotes.Factory changeNotesFactory,
     179             :       @Nullable DraftCommentNotes.Factory draftNotesFactory,
     180             :       @Nullable CommentsUtil commentsUtil,
     181             :       @Nullable ChangeUpdate.Factory changeUpdateFactory,
     182           2 :       @Nullable IdentifiedUser.GenericFactory userFactory) {
     183           2 :     this.allUsers = allUsers;
     184           2 :     this.repoManager = repoManager;
     185           2 :     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
     186           2 :     this.dryRun = dryRun;
     187           2 :     this.uiConsumer = uiConsumer;
     188           2 :     this.draftNotesFactory = draftNotesFactory;
     189           2 :     this.changeNotesFactory = changeNotesFactory;
     190           2 :     this.commentsUtil = commentsUtil;
     191           2 :     this.changeUpdateFactory = changeUpdateFactory;
     192           2 :     this.userFactory = userFactory;
     193           2 :   }
     194             : 
     195             :   public void execute() throws IOException {
     196           1 :     deleteDraftRefsThatPointToEmptyTree();
     197           1 :     if (draftNotesFactory != null) {
     198           0 :       deleteDraftCommentsThatAreAlsoPublished();
     199             :     }
     200           1 :   }
     201             : 
     202             :   private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
     203           1 :     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
     204           1 :       List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
     205           1 :       List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
     206             : 
     207           1 :       logInfo(
     208           1 :           String.format(
     209             :               "Found a total of %d zombie draft refs in %s repo.",
     210           1 :               zombieRefs.size(), allUsers.get()));
     211             : 
     212           1 :       logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
     213           1 :       zombieRefs =
     214           1 :           zombieRefs.stream()
     215           1 :               .filter(
     216           1 :                   ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
     217           1 :               .collect(toImmutableList());
     218           1 :       logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
     219             : 
     220           1 :       if (dryRun) {
     221           0 :         logInfo(
     222             :             "Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
     223           0 :         return;
     224             :       }
     225             : 
     226           1 :       long zombieRefsCnt = zombieRefs.size();
     227           1 :       long deletedRefsCnt = 0;
     228           1 :       long startTime = System.currentTimeMillis();
     229             : 
     230           1 :       for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
     231           1 :         deleteBatchZombieRefs(allUsersRepo, refsBatch);
     232           1 :         long elapsed = (System.currentTimeMillis() - startTime) / 1000;
     233           1 :         deletedRefsCnt += refsBatch.size();
     234           1 :         logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
     235           1 :       }
     236           0 :     }
     237           1 :   }
     238             : 
     239             :   /**
     240             :    * Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
     241             :    * exists a published comment with the same UUID and deletes the draft ref if that's the case
     242             :    * because it is a zombie draft.
     243             :    *
     244             :    * @return the number of detected and deleted zombie draft comments.
     245             :    */
     246             :   @VisibleForTesting
     247             :   public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
     248           1 :     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
     249           1 :       Timestamp earliestZombieTs = null;
     250           1 :       Timestamp latestZombieTs = null;
     251           1 :       int numZombies = 0;
     252           1 :       List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
     253             :       // Filter the number of draft refs to be processed according to the cleanup percentage.
     254           1 :       draftRefs =
     255           1 :           draftRefs.stream()
     256           1 :               .filter(
     257           1 :                   ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
     258           1 :               .collect(toImmutableList());
     259           1 :       Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
     260           1 :       ImmutableSet<Change.Id> changeIds =
     261           1 :           draftRefs.stream()
     262           1 :               .map(d -> Change.Id.fromAllUsersRef(d.getName()))
     263           1 :               .collect(ImmutableSet.toImmutableSet());
     264           1 :       Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
     265           1 :       for (Ref draftRef : draftRefs) {
     266             :         try {
     267           1 :           Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
     268           1 :           Account.Id accountId = Account.Id.fromRef(draftRef.getName());
     269           1 :           ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
     270           1 :           if (!visitedSet.add(changeUserIDsPair)) {
     271           0 :             continue;
     272             :           }
     273           1 :           if (!changeProjectMap.containsKey(changeId)) {
     274           0 :             logger.atWarning().log(
     275             :                 "Could not find a project associated with change ID %s. Skipping draft ref %s.",
     276           0 :                 changeId, draftRef.getName());
     277           0 :             continue;
     278             :           }
     279           1 :           DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
     280           1 :           ChangeNotes notes =
     281           1 :               changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
     282           1 :           List<HumanComment> drafts = draftNotes.getComments().values().asList();
     283           1 :           List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
     284           1 :           Set<String> publishedIds = toUuid(published);
     285           1 :           List<HumanComment> zombieDrafts =
     286           1 :               drafts.stream()
     287           1 :                   .filter(draft -> publishedIds.contains(draft.key.uuid))
     288           1 :                   .collect(Collectors.toList());
     289           1 :           for (HumanComment zombieDraft : zombieDrafts) {
     290           1 :             earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
     291           1 :             latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
     292           1 :           }
     293           1 :           zombieDrafts.forEach(
     294             :               zombieDraft ->
     295           1 :                   logger.atWarning().log(
     296             :                       "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
     297             :                           + " is a zombie draft that is already published.",
     298             :                       zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
     299           1 :           if (!zombieDrafts.isEmpty() && !dryRun) {
     300           1 :             deleteZombieComments(accountId, notes, zombieDrafts);
     301             :           }
     302           1 :           numZombies += zombieDrafts.size();
     303           0 :         } catch (Exception e) {
     304           0 :           logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
     305           1 :         }
     306           1 :       }
     307           1 :       if (numZombies > 0) {
     308           1 :         logger.atWarning().log(
     309             :             "Detected %d additional zombie drafts (earliest at %s, latest at %s).",
     310           1 :             numZombies, earliestZombieTs, latestZombieTs);
     311             :       }
     312           1 :       return numZombies;
     313             :     }
     314             :   }
     315             : 
     316             :   @AutoValue
     317           1 :   abstract static class ChangeUserIDsPair {
     318             :     abstract Change.Id changeId();
     319             : 
     320             :     abstract Account.Id accountId();
     321             : 
     322             :     static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
     323           1 :       return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
     324             :     }
     325             :   }
     326             : 
     327             :   /**
     328             :    * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
     329             :    * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
     330             :    * draft.
     331             :    */
     332             :   private void deleteZombieComments(
     333             :       Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> draftsToDelete)
     334             :       throws IOException {
     335           1 :     if (changeUpdateFactory == null || userFactory == null) {
     336           0 :       return;
     337             :     }
     338           1 :     ChangeUpdate changeUpdate =
     339           1 :         changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
     340           1 :     draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
     341           1 :     changeUpdate.commit();
     342           1 :     logger.atInfo().log(
     343             :         "Deleted zombie draft comments with UUIDs %s",
     344           1 :         draftsToDelete.stream().map(d -> d.key.uuid).collect(Collectors.toList()));
     345           1 :   }
     346             : 
     347             :   /**
     348             :    * Map each change ID to its associated project.
     349             :    *
     350             :    * <p>When doing a ref scan of draft refs
     351             :    * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
     352             :    * draft comment is associated with. The project name is needed to load published comments for the
     353             :    * change, hence we map each change ID to its project here by scanning through the change meta ref
     354             :    * of the change ID in all projects.
     355             :    */
     356             :   private Map<Change.Id, Project.NameKey> mapChangeIdsToProjects(
     357             :       ImmutableSet<Change.Id> changeIds) {
     358           1 :     Map<Change.Id, Project.NameKey> result = new HashMap<>();
     359           1 :     for (Project.NameKey project : repoManager.list()) {
     360           1 :       try (Repository repo = repoManager.openRepository(project)) {
     361           1 :         SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
     362           1 :         for (Change.Id changeId : unmappedChangeIds) {
     363           1 :           Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
     364           1 :           if (ref != null) {
     365           1 :             result.put(changeId, project);
     366             :           }
     367           1 :         }
     368           0 :       } catch (Exception e) {
     369           0 :         logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
     370           1 :       }
     371           1 :       if (changeIds.size() == result.size()) {
     372             :         // We do not need to scan the remaining repositories
     373           1 :         break;
     374             :       }
     375           1 :     }
     376           1 :     if (result.size() != changeIds.size()) {
     377           0 :       logger.atWarning().log(
     378             :           "Failed to associate the following change Ids to a project: %s",
     379           0 :           Sets.difference(changeIds, result.keySet()));
     380             :     }
     381           1 :     return result;
     382             :   }
     383             : 
     384             :   /** Map the list of input comments to their UUIDs. */
     385             :   private Set<String> toUuid(List<HumanComment> in) {
     386           1 :     return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
     387             :   }
     388             : 
     389             :   private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
     390           1 :     if (t1 == null) {
     391           1 :       return t2;
     392             :     }
     393           0 :     return t1.before(t2) ? t1 : t2;
     394             :   }
     395             : 
     396             :   private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
     397           1 :     if (t1 == null) {
     398           1 :       return t2;
     399             :     }
     400           0 :     return t1.after(t2) ? t1 : t2;
     401             :   }
     402             : 
     403             :   private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
     404             :       throws IOException {
     405           1 :     List<ReceiveCommand> deleteCommands =
     406           1 :         refsBatch.stream()
     407           1 :             .map(
     408             :                 zombieRef ->
     409           1 :                     new ReceiveCommand(
     410           1 :                         zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
     411           1 :             .collect(toImmutableList());
     412           1 :     BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
     413           1 :     bru.setAtomic(true);
     414           1 :     bru.addCommand(deleteCommands);
     415           1 :     RefUpdateUtil.executeChecked(bru, allUsersRepo);
     416           1 :   }
     417             : 
     418             :   private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
     419             :       throws IOException {
     420           1 :     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     421           1 :     for (Ref ref : allDraftRefs) {
     422           1 :       if (isZombieRef(allUsersRepo, ref)) {
     423           1 :         zombieRefs.add(ref);
     424             :       }
     425           1 :     }
     426           1 :     return zombieRefs;
     427             :   }
     428             : 
     429             :   private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
     430           1 :     return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
     431             :   }
     432             : 
     433             :   private void logInfo(String message) {
     434           1 :     logger.atInfo().log("%s", message);
     435           1 :     uiConsumer.accept(message);
     436           1 :   }
     437             : 
     438             :   private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
     439           1 :     logInfo(
     440           1 :         String.format(
     441             :             "Deleted %d/%d zombie draft refs (%d seconds)",
     442           1 :             deletedRefsCount, allRefsCount, elapsed));
     443           1 :   }
     444             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750