blob: c8d93f83f2ec4de2ad689657f95fee39d165d73d [file] [log] [blame]
// 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 org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
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.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.CommentsUtil;
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.util.time.TimeUtil;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
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 java.util.stream.Collectors;
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 refs. 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 {@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.
* <li>Inspecting all draft-comment refs. 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 #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
* will also be deleted.
* </uL>
*/
public class DeleteZombieCommentsRefs {
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;
private final int cleanupPercentage;
/**
* Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
* deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
* run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
* true.
*/
private final boolean dryRun;
private final Consumer<String> uiConsumer;
@Nullable private final DraftCommentNotes.Factory draftNotesFactory;
@Nullable private final ChangeNotes.Factory changeNotesFactory;
@Nullable private final CommentsUtil commentsUtil;
@Nullable private final ChangeUpdate.Factory changeUpdateFactory;
@Nullable private final IdentifiedUser.GenericFactory userFactory;
public interface Factory {
DeleteZombieCommentsRefs create(int cleanupPercentage);
DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
}
@AssistedInject
public DeleteZombieCommentsRefs(
AllUsersName allUsers,
GitRepositoryManager repoManager,
ChangeNotes.Factory changeNotesFactory,
DraftCommentNotes.Factory draftNotesFactory,
CommentsUtil commentsUtil,
ChangeUpdate.Factory changeUpdateFactory,
IdentifiedUser.GenericFactory userFactory,
@Assisted Integer cleanupPercentage) {
this(
allUsers,
repoManager,
cleanupPercentage,
/* dryRun= */ true,
(msg) -> {},
changeNotesFactory,
draftNotesFactory,
commentsUtil,
changeUpdateFactory,
userFactory);
}
@AssistedInject
public DeleteZombieCommentsRefs(
AllUsersName allUsers,
GitRepositoryManager repoManager,
ChangeNotes.Factory changeNotesFactory,
DraftCommentNotes.Factory draftNotesFactory,
CommentsUtil commentsUtil,
ChangeUpdate.Factory changeUpdateFactory,
IdentifiedUser.GenericFactory userFactory,
@Assisted Integer cleanupPercentage,
@Assisted boolean dryRun) {
this(
allUsers,
repoManager,
cleanupPercentage,
dryRun,
(msg) -> {},
changeNotesFactory,
draftNotesFactory,
commentsUtil,
changeUpdateFactory,
userFactory);
}
public DeleteZombieCommentsRefs(
AllUsersName allUsers,
GitRepositoryManager repoManager,
Integer cleanupPercentage,
Consumer<String> uiConsumer) {
this(
allUsers,
repoManager,
cleanupPercentage,
/* dryRun= */ false,
uiConsumer,
null,
null,
null,
null,
null);
}
private DeleteZombieCommentsRefs(
AllUsersName allUsers,
GitRepositoryManager repoManager,
Integer cleanupPercentage,
boolean dryRun,
Consumer<String> uiConsumer,
@Nullable ChangeNotes.Factory changeNotesFactory,
@Nullable DraftCommentNotes.Factory draftNotesFactory,
@Nullable CommentsUtil commentsUtil,
@Nullable ChangeUpdate.Factory changeUpdateFactory,
@Nullable IdentifiedUser.GenericFactory userFactory) {
this.allUsers = allUsers;
this.repoManager = repoManager;
this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
this.dryRun = dryRun;
this.uiConsumer = uiConsumer;
this.draftNotesFactory = draftNotesFactory;
this.changeNotesFactory = changeNotesFactory;
this.commentsUtil = commentsUtil;
this.changeUpdateFactory = changeUpdateFactory;
this.userFactory = userFactory;
}
public void execute() throws IOException {
deleteDraftRefsThatPointToEmptyTree();
if (draftNotesFactory != null) {
deleteDraftCommentsThatAreAlsoPublished();
}
}
private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
logInfo(
String.format(
"Found a total of %d zombie draft refs in %s repo.",
zombieRefs.size(), allUsers.get()));
logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
zombieRefs =
zombieRefs.stream()
.filter(
ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
.collect(toImmutableList());
logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
if (dryRun) {
logInfo(
"Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
return;
}
long zombieRefsCnt = zombieRefs.size();
long deletedRefsCnt = 0;
long startTime = System.currentTimeMillis();
for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
deleteBatchZombieRefs(allUsersRepo, refsBatch);
long elapsed = (System.currentTimeMillis() - startTime) / 1000;
deletedRefsCnt += refsBatch.size();
logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
}
}
}
/**
* Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
* exists a published comment with the same UUID and deletes the draft ref if that's the case
* because it is a zombie draft.
*
* @return the number of detected and deleted zombie draft comments.
*/
@VisibleForTesting
public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
Timestamp earliestZombieTs = null;
Timestamp latestZombieTs = null;
int numZombies = 0;
List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
// Filter the number of draft refs to be processed according to the cleanup percentage.
draftRefs =
draftRefs.stream()
.filter(
ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
.collect(toImmutableList());
Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
ImmutableSet<Change.Id> changeIds =
draftRefs.stream()
.map(d -> Change.Id.fromAllUsersRef(d.getName()))
.collect(ImmutableSet.toImmutableSet());
Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
for (Ref draftRef : draftRefs) {
try {
Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
Account.Id accountId = Account.Id.fromRef(draftRef.getName());
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 ref %s.",
changeId, draftRef.getName());
continue;
}
DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
ChangeNotes notes =
changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
List<HumanComment> drafts = draftNotes.getComments().values().asList();
List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
Set<String> publishedIds = toUuid(published);
List<HumanComment> zombieDrafts =
drafts.stream()
.filter(draft -> publishedIds.contains(draft.key.uuid))
.collect(Collectors.toList());
for (HumanComment zombieDraft : zombieDrafts) {
earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
}
zombieDrafts.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));
if (!zombieDrafts.isEmpty() && !dryRun) {
deleteZombieComments(accountId, notes, zombieDrafts);
}
numZombies += zombieDrafts.size();
} catch (Exception e) {
logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
}
}
if (numZombies > 0) {
logger.atWarning().log(
"Detected %d additional zombie drafts (earliest at %s, latest at %s).",
numZombies, earliestZombieTs, latestZombieTs);
}
return numZombies;
}
}
@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_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
}
}
/**
* 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 deleteZombieComments(
Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> 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(Collectors.toList()));
}
/**
* 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> mapChangeIdsToProjects(
ImmutableSet<Change.Id> changeIds) {
Map<Change.Id, Project.NameKey> result = new HashMap<>();
for (Project.NameKey project : repoManager.list()) {
try (Repository repo = repoManager.openRepository(project)) {
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;
}
/** 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(Collectors.toSet());
}
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;
}
private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
throws IOException {
List<ReceiveCommand> 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);
}
private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
throws IOException {
List<Ref> 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 logInfo(String message) {
logger.atInfo().log("%s", message);
uiConsumer.accept(message);
}
private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
logInfo(
String.format(
"Deleted %d/%d zombie draft refs (%d seconds)",
deletedRefsCount, allRefsCount, elapsed));
}
}