blob: cf15c950a1638119625f8fab7d11cc5896c45e4b [file] [log] [blame]
// 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);
ImmutableSet<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 ImmutableSet<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;
}
}