| // Copyright (C) 2023 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; |
| |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.collect.ImmutableList.toImmutableList; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| |
| import com.google.auto.value.AutoValue; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.collect.ArrayListMultimap; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.ListMultimap; |
| import com.google.common.collect.Sets; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; |
| 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.entities.Project; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.function.Consumer; |
| import org.eclipse.jgit.lib.Ref; |
| import org.eclipse.jgit.lib.Repository; |
| |
| /** |
| * This class can be used to clean zombie draft comments. More context in <a |
| * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233"> |
| * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a> |
| * |
| * <p>The implementation has two cases for detecting zombie drafts: |
| * |
| * <ul> |
| * <li>An earlier bug in the deletion of draft comments caused some draft refs to remain empty but |
| * not get deleted. |
| * <li>Inspecting all draft-comments. Check for each draft if there exists a published comment |
| * with the same UUID. These comments are called zombie drafts. If the program is run in |
| * {@link DeleteZombieComments#dryRun} mode, the zombie draft IDs will only be logged for |
| * tracking, otherwise they will also be deleted. |
| * </uL> |
| */ |
| public abstract class DeleteZombieComments<KeyT> implements AutoCloseable { |
| @AutoValue |
| abstract static class ChangeUserIDsPair { |
| abstract Change.Id changeId(); |
| |
| abstract Account.Id accountId(); |
| |
| static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) { |
| return new AutoValue_DeleteZombieComments_ChangeUserIDsPair(changeId, accountId); |
| } |
| } |
| |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private final int cleanupPercentage; |
| protected final boolean dryRun; |
| @Nullable private final Consumer<String> uiConsumer; |
| @Nullable private final GitRepositoryManager repoManager; |
| @Nullable private final DraftCommentsReader draftCommentsReader; |
| @Nullable private final ChangeNotes.Factory changeNotesFactory; |
| @Nullable private final CommentsUtil commentsUtil; |
| |
| private Map<Change.Id, Project.NameKey> changeProjectMap = new HashMap<>(); |
| private Map<Change.Id, ChangeNotes> changeNotes = new HashMap<>(); |
| |
| protected DeleteZombieComments( |
| Integer cleanupPercentage, |
| boolean dryRun, |
| Consumer<String> uiConsumer, |
| GitRepositoryManager repoManager, |
| DraftCommentsReader draftCommentsReader, |
| ChangeNotes.Factory changeNotesFactory, |
| CommentsUtil commentsUtil) { |
| this.cleanupPercentage = cleanupPercentage == null ? 100 : cleanupPercentage; |
| this.dryRun = dryRun; |
| this.uiConsumer = uiConsumer; |
| this.repoManager = repoManager; |
| this.draftCommentsReader = draftCommentsReader; |
| this.changeNotesFactory = changeNotesFactory; |
| this.commentsUtil = commentsUtil; |
| } |
| |
| /** Deletes all draft comments. Returns the number of zombie draft comments that were deleted. */ |
| @CanIgnoreReturnValue |
| public int execute() throws IOException { |
| setup(); |
| ListMultimap<KeyT, HumanComment> alreadyPublished = listDraftCommentsThatAreAlsoPublished(); |
| if (!dryRun) { |
| deleteZombieDrafts(alreadyPublished); |
| } |
| |
| List<KeyT> emptyDrafts = filterByCleanupPercentage(listEmptyDrafts(), "empty"); |
| if (!dryRun) { |
| deleteEmptyDraftsByKey(emptyDrafts); |
| } else { |
| logInfo( |
| String.format( |
| "Running in dry run mode. Skipping deletion." |
| + "\nStats (with %d cleanup-percentage):" |
| + "\nEmpty drafts = %d" |
| + "\nAlready published drafts (zombies) = %d", |
| cleanupPercentage, emptyDrafts.size(), alreadyPublished.size())); |
| } |
| return emptyDrafts.size() + alreadyPublished.size(); |
| } |
| |
| @VisibleForTesting |
| public abstract void setup() throws IOException; |
| |
| @Override |
| public abstract void close() throws IOException; |
| |
| protected abstract List<KeyT> listAllDrafts() throws IOException; |
| |
| protected abstract List<KeyT> listEmptyDrafts() throws IOException; |
| |
| protected abstract void deleteEmptyDraftsByKey(Collection<KeyT> keys) throws IOException; |
| |
| protected abstract void deleteZombieDrafts(ListMultimap<KeyT, HumanComment> drafts) |
| throws IOException; |
| |
| protected abstract Change.Id getChangeId(KeyT key); |
| |
| protected abstract Account.Id getAccountId(KeyT key); |
| |
| protected abstract String loggable(KeyT key); |
| |
| protected ChangeNotes getChangeNotes(Change.Id changeId) { |
| if (changeNotes.containsKey(changeId)) { |
| return changeNotes.get(changeId); |
| } |
| checkState( |
| changeProjectMap.containsKey(changeId), |
| "Cannot get a project associated with change ID " + changeId); |
| ChangeNotes notes = changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId); |
| changeNotes.put(changeId, notes); |
| return notes; |
| } |
| |
| private List<KeyT> filterByCleanupPercentage(List<KeyT> drafts, String reason) { |
| if (cleanupPercentage >= 100) { |
| logInfo( |
| String.format( |
| "Cleanup percentage = %d" + "\nNumber of drafts to be cleaned for %s = %d", |
| cleanupPercentage, reason, drafts.size())); |
| return drafts; |
| } |
| ImmutableList<KeyT> res = |
| drafts.stream() |
| .filter(key -> getChangeId(key).get() % 100 < cleanupPercentage) |
| .collect(toImmutableList()); |
| logInfo( |
| String.format( |
| "Cleanup percentage = %d" |
| + "\nOriginal number of drafts for %s = %d" |
| + "\nNumber of drafts to be processed for %s = %d", |
| cleanupPercentage, reason, drafts.size(), reason, res.size())); |
| return res; |
| } |
| |
| @VisibleForTesting |
| public ListMultimap<KeyT, HumanComment> listDraftCommentsThatAreAlsoPublished() |
| throws IOException { |
| List<KeyT> draftKeys = filterByCleanupPercentage(listAllDrafts(), "all-drafts"); |
| changeProjectMap.putAll(mapChangesWithDraftsToProjects(draftKeys)); |
| |
| ListMultimap<KeyT, HumanComment> zombieDrafts = ArrayListMultimap.create(); |
| Set<ChangeUserIDsPair> visitedSet = new HashSet<>(); |
| for (KeyT key : draftKeys) { |
| try { |
| Change.Id changeId = getChangeId(key); |
| Account.Id accountId = getAccountId(key); |
| ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId); |
| if (!visitedSet.add(changeUserIDsPair)) { |
| continue; |
| } |
| if (!changeProjectMap.containsKey(changeId)) { |
| logger.atWarning().log( |
| "Could not find a project associated with change ID %s. Skipping draft [%s]", |
| changeId, loggable(key)); |
| continue; |
| } |
| List<HumanComment> drafts = |
| draftCommentsReader.getDraftsByChangeAndDraftAuthor(changeId, accountId); |
| ChangeNotes notes = getChangeNotes(changeId); |
| List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes); |
| Set<String> publishedIds = toUuid(published); |
| ImmutableList<HumanComment> zombieDraftsForChangeAndAuthor = |
| drafts.stream() |
| .filter(draft -> publishedIds.contains(draft.key.uuid)) |
| .collect(toImmutableList()); |
| zombieDraftsForChangeAndAuthor.forEach( |
| zombieDraft -> |
| logger.atWarning().log( |
| "Draft comment with uuid '%s' of change %s, account %s, written on %s," |
| + " is a zombie draft that is already published.", |
| zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn)); |
| zombieDrafts.putAll(key, zombieDraftsForChangeAndAuthor); |
| } catch (RuntimeException e) { |
| logger.atWarning().withCause(e).log("Failed to process draft [%s]", loggable(key)); |
| } |
| } |
| |
| if (!zombieDrafts.isEmpty()) { |
| Timestamp earliestZombieTs = null; |
| Timestamp latestZombieTs = null; |
| for (HumanComment zombieDraft : zombieDrafts.values()) { |
| earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn); |
| latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn); |
| } |
| logger.atWarning().log( |
| "Detected %d zombie drafts that were already published (earliest at %s, latest at %s).", |
| zombieDrafts.size(), earliestZombieTs, latestZombieTs); |
| } |
| return zombieDrafts; |
| } |
| |
| /** |
| * Map each change ID to its associated project. |
| * |
| * <p>When doing a ref scan of draft refs |
| * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this |
| * draft comment is associated with. The project name is needed to load published comments for the |
| * change, hence we map each change ID to its project here by scanning through the change meta ref |
| * of the change ID in all projects. |
| */ |
| private Map<Change.Id, Project.NameKey> mapChangesWithDraftsToProjects(List<KeyT> drafts) { |
| ImmutableSet<Change.Id> changeIds = |
| drafts.stream().map(key -> getChangeId(key)).collect(ImmutableSet.toImmutableSet()); |
| Map<Change.Id, Project.NameKey> result = new HashMap<>(); |
| for (Project.NameKey project : repoManager.list()) { |
| try (Repository repo = repoManager.openRepository(project)) { |
| Sets.SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet()); |
| for (Change.Id changeId : unmappedChangeIds) { |
| Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId)); |
| if (ref != null) { |
| result.put(changeId, project); |
| } |
| } |
| } catch (Exception e) { |
| logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project); |
| } |
| if (changeIds.size() == result.size()) { |
| // We do not need to scan the remaining repositories |
| break; |
| } |
| } |
| if (result.size() != changeIds.size()) { |
| logger.atWarning().log( |
| "Failed to associate the following change Ids to a project: %s", |
| Sets.difference(changeIds, result.keySet())); |
| } |
| return result; |
| } |
| |
| protected void logInfo(String message) { |
| logger.atInfo().log("%s", message); |
| uiConsumer.accept(message); |
| } |
| |
| /** Map the list of input comments to their UUIDs. */ |
| private Set<String> toUuid(List<HumanComment> in) { |
| return in.stream().map(c -> c.key.uuid).collect(toImmutableSet()); |
| } |
| |
| private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) { |
| if (t1 == null) { |
| return t2; |
| } |
| return t1.before(t2) ? t1 : t2; |
| } |
| |
| private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) { |
| if (t1 == null) { |
| return t2; |
| } |
| return t1.after(t2) ? t1 : t2; |
| } |
| } |