Bound trash folder deletion with configurable timeout Large sites could spend too long scanning and deleting trash folders, leading to prolonged intensive I/O and increased system load. This change introduces a time cap for the deletion. A new configuration key, `deleteTrashFoldersMaxAllowedTime` (default: 10 min), bounds the execution time of the trash-folder sweep. This keeps long runs from monopolizing the worker while preserving the existing behavior within the allotted time window. Subsequent runs handle any remaining trash folders. Change-Id: I964c52ff74da7dba268128063a68d57cdf39a8df
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java index f345c5c..04bd5fa 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
@@ -16,6 +16,8 @@ import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.toList; import com.google.common.base.Strings; @@ -43,11 +45,13 @@ private static final FluentLogger log = FluentLogger.forEnclosingClass(); private static final String DELETED_PROJECTS_PARENT = "Deleted-Projects"; private static final long DEFAULT_ARCHIVE_DURATION_DAYS = 180; + private static final long DEFAULT_TRASH_FOLDER_MAX_ALLOWED_TIME_MINUTES = 10; private final boolean allowDeletionWithTags; private final boolean archiveDeletedRepos; private final boolean hideProjectOnPreserve; private final long deleteArchivedReposAfter; + private final long deleteTrashFoldersMaxAllowedTime; private final String deletedProjectsParent; private final Path archiveFolder; private final List<Pattern> protectedProjects; @@ -68,6 +72,8 @@ this.hideProjectOnPreserve = cfg.getBoolean("hideProjectOnPreserve", false); this.deletedProjectsParent = cfg.getString("parentForDeletedProjects", DELETED_PROJECTS_PARENT); this.archiveDeletedRepos = cfg.getBoolean("archiveDeletedRepos", false); + this.deleteTrashFoldersMaxAllowedTime = + getTrashFoldersMaxAllowedTimeFromConfig("deleteTrashFoldersMaxAllowedTime"); this.archiveFolder = getArchiveFolderFromConfig(cfg.getString("archiveFolder", pluginData.toString())); this.deleteArchivedReposAfter = @@ -86,6 +92,10 @@ .buildSchedule(); } + public long getDeleteTrashFoldersMaxAllowedTime() { + return deleteTrashFoldersMaxAllowedTime; + } + public boolean deletionWithTagsAllowed() { return allowDeletionWithTags; } @@ -137,6 +147,19 @@ } } + private long getTrashFoldersMaxAllowedTimeFromConfig(String configValue) { + long defaultConfigValue = MINUTES.toSeconds(DEFAULT_TRASH_FOLDER_MAX_ALLOWED_TIME_MINUTES); + try { + return ConfigUtil.getTimeUnit(configValue, defaultConfigValue, SECONDS); + } catch (IllegalArgumentException e) { + log.atWarning().log( + "The configured trash folder max allowed time is not valid: %s; using the default value:" + + " %d minutes", + e.getMessage(), DEFAULT_TRASH_FOLDER_MAX_ALLOWED_TIME_MINUTES); + return defaultConfigValue; + } + } + public Optional<ScheduleConfig.Schedule> getSchedule() { return schedule; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java index 7a2b6aa..51551ef 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java
@@ -17,6 +17,7 @@ import static java.util.concurrent.Executors.callable; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Stopwatch; import com.google.common.collect.Sets; import com.google.common.flogger.FluentLogger; import com.google.common.io.MoreFiles; @@ -33,6 +34,7 @@ import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Iterator; import java.util.Optional; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; @@ -89,6 +91,7 @@ private ScheduledFuture<?> threadCompleted; private final Optional<ScheduleConfig.Schedule> schedule; + private final long deleteTrashFoldersMaxAllowedTime; @Inject public DeleteTrashFolders( @@ -102,6 +105,7 @@ repoFolders.add(site.resolve(cfg.getString("gerrit", null, "basePath"))); repoFolders.addAll(repositoryCfg.getAllBasePaths()); schedule = pluginCfg.getSchedule(); + deleteTrashFoldersMaxAllowedTime = pluginCfg.getDeleteTrashFoldersMaxAllowedTime(); this.workQueue = workQueue; this.pluginName = pluginName; } @@ -113,7 +117,7 @@ new Runnable() { @Override public void run() { - repoFolders.forEach(DeleteTrashFolders.this::evaluateIfTrash); + evaluateIfTrashWithTimeLimit(); } @Override @@ -137,16 +141,38 @@ } } - private void evaluateIfTrash(Path folder) { + private void evaluateIfTrashWithTimeLimit() { + Stopwatch stopWatch = Stopwatch.createStarted(); + for (Path folder : repoFolders) { + if (exceededMaxAllowedTime(folder, stopWatch)) break; + evaluateIfTrash(folder, stopWatch); + } + } + + private void evaluateIfTrash(Path folder, Stopwatch stopWatch) { try (Stream<Path> dir = Files.walk(folder, FileVisitOption.FOLLOW_LINKS)) { - dir.filter(Files::isDirectory) - .filter(TrashFolderPredicate::match) - .forEach(this::recursivelyDelete); + Iterator<Path> it = + dir.filter(Files::isDirectory).filter(TrashFolderPredicate::match).iterator(); + + while (it.hasNext()) { + if (exceededMaxAllowedTime(folder, stopWatch)) break; + recursivelyDelete(it.next()); + } } catch (IOException e) { log.atSevere().withCause(e).log("Failed to evaluate %s", folder); } } + private boolean exceededMaxAllowedTime(Path folder, Stopwatch stopWatch) { + if (stopWatch.elapsed(TimeUnit.SECONDS) >= deleteTrashFoldersMaxAllowedTime) { + log.atWarning().log( + "Stopping early: exceeded max duration (%d s) while scanning %s", + deleteTrashFoldersMaxAllowedTime, folder); + return true; + } + return false; + } + @VisibleForTesting ScheduledFuture<?> getWorkerFuture() { return threadCompleted;
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index a00c131..cae3bfe 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -105,6 +105,25 @@ By default 180 (days). +plugin.@PLUGIN@.deleteTrashFoldersMaxAllowedTime +: Maximum duration to delete trash folders. + + Specifies the maximum duration that the plugin will spend + scanning and deleting trash folders during a scheduled cleanup run. + + The following suffixes are supported to define the time unit: + 1. d, day, days + 2. w, week, weeks (1 week is treated as 7 days) + 3. mon, month, months (1 month is treated as 30 days) + 4. y, year, years (1 year is treated as 365 days) + + If the operation exceeds this duration, the process stops early to avoid long-running + deletions that could overload the system or block other scheduled tasks. + + Subsequent runs handle any remaining trash folders. + + By default 10 minutes. + Delete Trash Folder Scheduling =============
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java index 487b8eb..4f6098e 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java
@@ -14,6 +14,7 @@ package com.googlesource.gerrit.plugins.deleteproject.fs; +import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.when; @@ -33,6 +34,7 @@ import java.time.format.DateTimeFormatter; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import org.eclipse.jgit.internal.storage.file.FileRepository; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Repository; @@ -74,6 +76,7 @@ fakeScheduledExecutor = new FakeScheduledExecutorService(); when(repositoryCfg.getAllBasePaths()).thenReturn(ImmutableList.of()); when(workQueue.getDefaultQueue()).thenReturn(fakeScheduledExecutor); + when(pluginCfg.getDeleteTrashFoldersMaxAllowedTime()).thenReturn(10L); trashFolders = new DeleteTrashFolders( sitePaths, cfg, repositoryCfg, pluginCfg, workQueue, DELETE_PROJECT_PLUGIN); @@ -136,6 +139,30 @@ assertThatRepositoryExists(repoToKeep); } + @Test + public void shouldStopProcessingWhenTimeoutExceeded() throws IOException { + when(pluginCfg.getDeleteTrashFoldersMaxAllowedTime()).thenReturn(0L); + + DeleteTrashFolders deleteTrashFolders = + new DeleteTrashFolders( + sitePaths, cfg, repositoryCfg, pluginCfg, workQueue, DELETE_PROJECT_PLUGIN); + + for (int i = 0; i < 10; i++) { + Path trash = basePath.resolve(String.format("repo.%013d.deleted", i)); + Files.createDirectories(trash); + } + + deleteTrashFolders.start(); + deleteTrashFolders.getWorkerFuture().cancel(true); + + Stream<Path> remaining = + Files.walk(basePath) + .filter(Files::isDirectory) + .filter(DeleteTrashFolders.TrashFolderPredicate::match); + + assertThat(remaining.count()).isGreaterThan(0L); + } + private FileRepository createRepository(String repoName) throws IOException { Path repoPath = Files.createDirectories(basePath.resolve(repoName)); Repository repository = new FileRepository(repoPath.toFile());