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());