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 : }
|