Make trash cleanup schedulable, keep startup default

Run deletion of repository trash folders on a configurable schedule to
avoid high load at startup on large sites and to treat the cleanup as
regular maintenance.

This reduces startup impact and ensures trash directories are cleaned
periodically without manual intervention.

This change preserves prior behavior by executing once at startup when
no schedule is configured.

Bug: Issue 457866789
Change-Id: I12e7f6e383e8040327c40c8294209bf8565b50c7
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 f5ad7e6..f345c5c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.extensions.annotations.PluginData;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.File;
@@ -32,7 +34,9 @@
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class Configuration {
@@ -47,6 +51,7 @@
   private final String deletedProjectsParent;
   private final Path archiveFolder;
   private final List<Pattern> protectedProjects;
+  private final Optional<ScheduleConfig.Schedule> schedule;
   private final PluginConfig cfg;
 
   private File pluginData;
@@ -55,7 +60,8 @@
   public Configuration(
       PluginConfigFactory pluginConfigFactory,
       @PluginName String pluginName,
-      @PluginData File pluginData) {
+      @PluginData File pluginData,
+      @GerritServerConfig Config gerritConfig) {
     this.cfg = pluginConfigFactory.getFromGerritConfig(pluginName);
     this.pluginData = pluginData;
     this.allowDeletionWithTags = cfg.getBoolean("allowDeletionOfReposWithTags", true);
@@ -71,6 +77,13 @@
         Arrays.asList(cfg.getStringList("protectedProject")).stream()
             .map(Pattern::compile)
             .collect(toList());
+    this.schedule =
+        ScheduleConfig.builder(gerritConfig, "plugin")
+            .setSubsection(pluginName)
+            .setKeyInterval("deleteTrashFolderInterval")
+            .setKeyStartTime("deleteTrashFolderStartTime")
+            .setKeyJitter("deleteTrashFolderJitter")
+            .buildSchedule();
   }
 
   public boolean deletionWithTagsAllowed() {
@@ -123,4 +136,8 @@
       return DAYS.toMillis(DEFAULT_ARCHIVE_DURATION_DAYS);
     }
   }
+
+  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 1150a56..7a2b6aa 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
@@ -14,24 +14,30 @@
 package com.googlesource.gerrit.plugins.deleteproject.fs;
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static java.util.concurrent.Executors.callable;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.MoreFiles;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.deleteproject.Configuration;
 import java.io.IOException;
 import java.nio.file.FileVisitOption;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Config;
@@ -40,6 +46,7 @@
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
   private final WorkQueue workQueue;
+  private final String pluginName;
 
   static class TrashFolderPredicate {
 
@@ -80,38 +87,54 @@
 
   private Set<Path> repoFolders;
 
-  private Future<Void> threadCompleted;
+  private ScheduledFuture<?> threadCompleted;
+  private final Optional<ScheduleConfig.Schedule> schedule;
 
   @Inject
   public DeleteTrashFolders(
       SitePaths site,
       @GerritServerConfig Config cfg,
       RepositoryConfig repositoryCfg,
-      WorkQueue workQueue) {
+      Configuration pluginCfg,
+      WorkQueue workQueue,
+      @PluginName String pluginName) {
     repoFolders = Sets.newHashSet();
     repoFolders.add(site.resolve(cfg.getString("gerrit", null, "basePath")));
     repoFolders.addAll(repositoryCfg.getAllBasePaths());
+    schedule = pluginCfg.getSchedule();
     this.workQueue = workQueue;
+    this.pluginName = pluginName;
   }
 
   @Override
   public void start() {
-    threadCompleted =
-        workQueue
-            .getDefaultQueue()
-            .submit(
-                new Callable<>() {
-                  @Override
-                  public Void call() {
-                    repoFolders.stream().forEach(DeleteTrashFolders.this::evaluateIfTrash);
-                    return null;
-                  }
+    String taskName = String.format("[%s]: DeleteTrashFolders under %s", pluginName, repoFolders);
+    Runnable deleteTrashFoldersRunnable =
+        new Runnable() {
+          @Override
+          public void run() {
+            repoFolders.forEach(DeleteTrashFolders.this::evaluateIfTrash);
+          }
 
-                  @Override
-                  public String toString() {
-                    return "DeleteTrashFolders";
-                  }
-                });
+          @Override
+          public String toString() {
+            return taskName;
+          }
+        };
+
+    ScheduledExecutorService scheduledExecutor = workQueue.getDefaultQueue();
+    if (schedule.isPresent()) {
+      threadCompleted =
+          scheduledExecutor.scheduleAtFixedRate(
+              deleteTrashFoldersRunnable,
+              schedule.get().initialDelay(),
+              schedule.get().interval(),
+              TimeUnit.MILLISECONDS);
+    } else {
+      threadCompleted =
+          scheduledExecutor.schedule(
+              callable(deleteTrashFoldersRunnable), 0, TimeUnit.MILLISECONDS);
+    }
   }
 
   private void evaluateIfTrash(Path folder) {
@@ -125,7 +148,7 @@
   }
 
   @VisibleForTesting
-  Future<Void> getWorkerFuture() {
+  ScheduledFuture<?> getWorkerFuture() {
     return threadCompleted;
   }
 
@@ -138,5 +161,10 @@
   }
 
   @Override
-  public void stop() {}
+  public void stop() {
+    if (threadCompleted != null) {
+      threadCompleted.cancel(true);
+      threadCompleted = null;
+    }
+  }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 4b5f6f2..a00c131 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -104,3 +104,33 @@
 	task.
 
 	By default 180 (days).
+
+Delete Trash Folder Scheduling
+=============
+
+Trash folder cleanup can be scheduled to run periodically.
+If no schedule is configured, the cleanup runs once at Gerrit startup.
+
+The configuration has to be added to the `@PLUGIN@.config` file.
+
+plugin.@PLUGIN@.deleteTrashFolderStartTime
+:	The start time for running trash folders deletion.
+
+	The [start time](/Documentation/config-gerrit.html#schedule-configuration-startTime)
+	for running trash folders deletion.
+
+plugin.@PLUGIN@.deleteTrashFolderInterval
+:	The interval between successive trash folder deletions.
+
+	The [interval](/Documentation/config-gerrit.html#schedule-configuration-interval)
+	for running trash folders deletion.
+
+plugin.@PLUGIN@.deleteTrashFolderJitter
+: A maximum random delay that will be added to the job’s scheduled start time.
+
+	See the [jitter documentation](/Documentation/config-gerrit.html#schedule-configuration-jitter)
+	for details.
+
+[Schedule examples](/Documentation/config-gerrit.html#schedule-configuration-examples)
+can be found in the [Schedule Configuration](/Documentation/config-gerrit.html#schedule-configuration)
+section.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
index 6740bd3..ed0023f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
@@ -56,7 +56,8 @@
   public void defaultValuesAreLoaded() {
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(PluginConfig.create(PLUGIN_NAME, new Config(), null));
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir, new Config());
 
     assertThat(deleteConfig.getDeletedProjectsParent()).isEqualTo("Deleted-Projects");
     assertThat(deleteConfig.deletionWithTagsAllowed()).isTrue();
@@ -78,7 +79,8 @@
 
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(pluginConfig.asPluginConfig());
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir, new Config());
 
     assertThat(deleteConfig.getDeletedProjectsParent()).isEqualTo(CUSTOM_PARENT);
     assertThat(deleteConfig.deletionWithTagsAllowed()).isFalse();
@@ -96,7 +98,8 @@
 
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(pluginConfig.asPluginConfig());
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir, new Config());
 
     assertThat(deleteConfig.getArchiveDuration())
         .isEqualTo(TimeUnit.DAYS.toMillis(Long.parseLong(CUSTOM_DURATION)) * 365);
@@ -109,7 +112,8 @@
 
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(pluginConfig.asPluginConfig());
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir, new Config());
 
     assertThat(deleteConfig.getArchiveDuration()).isEqualTo(DEFAULT_ARCHIVE_DURATION_MS);
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/FakeScheduledExecutorService.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/FakeScheduledExecutorService.java
new file mode 100644
index 0000000..c4a6652
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/FakeScheduledExecutorService.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2025 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.googlesource.gerrit.plugins.deleteproject;
+
+import static java.util.concurrent.Executors.callable;
+
+import com.google.common.util.concurrent.MoreExecutors;
+import org.junit.Ignore;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.List;
+import java.util.PriorityQueue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+@Ignore
+public class FakeScheduledExecutorService implements ScheduledExecutorService {
+  private ExecutorService directExecutor = MoreExecutors.newDirectExecutorService();
+  private Duration currentTime = Duration.ZERO;
+  private final PriorityQueue<FakeScheduledFuture<?>> taskQueue = new PriorityQueue<>();
+
+  public void advance(long delta, TimeUnit timeUnit) {
+    currentTime = currentTime.plusNanos(timeUnit.toNanos(delta));
+    runTasksInQueue();
+  }
+
+  private void runTasksInQueue() {
+    while (!taskQueue.isEmpty()
+        && taskQueue.peek().getDelay(TimeUnit.NANOSECONDS) <= currentTime.toNanos()) {
+      FakeScheduledFuture<?> taskToRun = taskQueue.poll();
+
+      if (!taskToRun.isCancelled()) {
+        taskToRun.run(currentTime);
+
+        if (taskToRun.getPeriod() > 0) {
+          FakeScheduledFuture<?> unused = queue(taskToRun);
+        }
+      }
+    }
+  }
+
+  private class FakeScheduledFuture<T> implements ScheduledFuture<T> {
+    private Future<T> future;
+    private final long period;
+    private final TimeUnit delayUnit;
+    private long delay;
+    private Callable<T> taskToRun;
+
+    FakeScheduledFuture(Callable<T> taskToRun, long delay, TimeUnit unit) {
+      this(taskToRun, delay, 0L, unit);
+    }
+
+    FakeScheduledFuture(Callable<T> taskToRun, long delay, long period, TimeUnit unit) {
+      this.taskToRun = taskToRun;
+      this.delay = delay;
+      this.period = period;
+      this.delayUnit = unit;
+
+      if (delay > 0 || period > 0) {
+        this.future = null;
+      } else {
+        this.future = directExecutor.submit(taskToRun);
+      }
+    }
+
+    @Override
+    public long getDelay(TimeUnit unit) {
+      return unit.convert(delay, delayUnit);
+    }
+
+    @Override
+    public int compareTo(Delayed o) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      if (future != null) {
+        return future.cancel(mayInterruptIfRunning);
+      }
+      taskToRun = null;
+      return true;
+    }
+
+    @Override
+    public boolean isCancelled() {
+      return future != null && future.isCancelled();
+    }
+
+    @Override
+    public boolean isDone() {
+      return future != null && future.isDone();
+    }
+
+    @Override
+    public T get() throws InterruptedException, ExecutionException {
+      return future.get();
+    }
+
+    @Override
+    public T get(long timeout, TimeUnit unit)
+        throws InterruptedException, ExecutionException, TimeoutException {
+      return future.get(timeout, unit);
+    }
+
+    void run(Duration currentTime) {
+      this.future = directExecutor.submit(taskToRun);
+      this.delay = delayUnit.convert(currentTime) + period;
+    }
+
+    public long getPeriod() {
+      return period;
+    }
+  }
+
+  @Override
+  public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
+    return queue(new FakeScheduledFuture<>(callable, delay, unit));
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleAtFixedRate(
+      Runnable command, long initialDelay, long period, TimeUnit unit) {
+    return queue(new FakeScheduledFuture<>(callable(command), initialDelay, period, unit));
+  }
+
+  @Override
+  public ScheduledFuture<?> scheduleWithFixedDelay(
+      Runnable command, long initialDelay, long delay, TimeUnit unit) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void shutdown() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public List<Runnable> shutdownNow() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isShutdown() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isTerminated() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> Future<T> submit(Callable<T> task) {
+    return directExecutor.submit(task);
+  }
+
+  @Override
+  public <T> Future<T> submit(Runnable task, T result) {
+    return directExecutor.submit(task, result);
+  }
+
+  @Override
+  public Future<?> submit(Runnable task) {
+    return directExecutor.submit(task);
+  }
+
+  @Override
+  public void close() {
+    directExecutor.close();
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> List<Future<T>> invokeAll(
+      Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
+      throws InterruptedException, ExecutionException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void execute(Runnable command) {
+    directExecutor.execute(command);
+  }
+
+  private <T> FakeScheduledFuture<T> queue(FakeScheduledFuture<T> scheduledFuture) {
+    if (scheduledFuture.getDelay(TimeUnit.NANOSECONDS) > 0) {
+      taskQueue.add(scheduledFuture);
+    }
+    return scheduledFuture;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
index 045592d..75b4562 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
@@ -53,7 +53,8 @@
     pluginConfig = PluginConfig.Update.forTest(PLUGIN_NAME, new Config());
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(pluginConfig.asPluginConfig());
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData, new Config());
     protectedProjects = new ProtectedProjects(allProjectsMock, allUsersMock, deleteConfig);
   }
 
@@ -78,7 +79,8 @@
     pluginConfig.setStringList("protectedProject", projects);
     when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
         .thenReturn(pluginConfig.asPluginConfig());
-    deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData);
+    deleteConfig =
+        new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData, new Config());
     assertThat(deleteConfig.protectedProjects()).hasSize(projects.size());
     protectedProjects = new ProtectedProjects(allProjectsMock, allUsersMock, deleteConfig);
 
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 9eda84e..487b8eb 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,17 +14,25 @@
 
 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;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.WorkQueue;
+import com.googlesource.gerrit.plugins.deleteproject.Configuration;
+import com.googlesource.gerrit.plugins.deleteproject.FakeScheduledExecutorService;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.concurrent.Executors;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
@@ -38,35 +46,94 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class DeleteTrashFoldersTest {
+  private static final String DELETE_PROJECT_PLUGIN = "delete-project";
+  private static final int INITIAL_DELAY_MIN = 2;
+  private static final int INTERVAL_DAYS = 1;
+  public static final String REPOSITORY_TO_DELETE = "repo.1234567890123.deleted";
 
   @Mock private RepositoryConfig repositoryCfg;
 
   @Mock private WorkQueue workQueue;
 
+  @Mock private Configuration pluginCfg;
+
   @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
 
+  private Config cfg;
   private Path basePath;
   private DeleteTrashFolders trashFolders;
+  private SitePaths sitePaths;
+  private FakeScheduledExecutorService fakeScheduledExecutor;
 
   @Before
   public void setUp() throws Exception {
-    SitePaths sitePaths = new SitePaths(tempFolder.newFolder("gerrit_site").toPath());
+    sitePaths = new SitePaths(tempFolder.newFolder("gerrit_site").toPath());
     basePath = sitePaths.resolve("base");
-    Config cfg = new Config();
+    cfg = new Config();
     cfg.setString("gerrit", null, "basePath", basePath.toString());
+    fakeScheduledExecutor = new FakeScheduledExecutorService();
     when(repositoryCfg.getAllBasePaths()).thenReturn(ImmutableList.of());
-    when(workQueue.getDefaultQueue()).thenReturn(Executors.newSingleThreadScheduledExecutor());
-    trashFolders = new DeleteTrashFolders(sitePaths, cfg, repositoryCfg, workQueue);
+    when(workQueue.getDefaultQueue()).thenReturn(fakeScheduledExecutor);
+    trashFolders =
+        new DeleteTrashFolders(
+            sitePaths, cfg, repositoryCfg, pluginCfg, workQueue, DELETE_PROJECT_PLUGIN);
+  }
+
+  @Test
+  public void testShouldDeleteRepositoryAfterInitialDelayAndPeriodically() throws Exception {
+    ZonedDateTime initialDateTime =
+        ZonedDateTime.now(ZoneId.systemDefault()).plusMinutes(INITIAL_DELAY_MIN);
+    String initialDateTimeFormatted = initialDateTime.format(DateTimeFormatter.ofPattern("HH:mm"));
+    setupTrashFolderCleanupSchedule(
+        initialDateTimeFormatted, String.format("%d days", INTERVAL_DAYS));
+
+    DeleteTrashFolders trashFolders =
+        new DeleteTrashFolders(
+            sitePaths, cfg, repositoryCfg, pluginCfg, workQueue, DELETE_PROJECT_PLUGIN);
+    trashFolders.start();
+
+    try (FileRepository repoToDelete = createRepository(REPOSITORY_TO_DELETE)) {
+      // Repository is not deleted at 1/2 time of the initial delay
+      fakeScheduledExecutor.advance(
+          TimeUnit.MINUTES.toSeconds(INITIAL_DELAY_MIN / 2), TimeUnit.SECONDS);
+      assertThatRepositoryExists(repoToDelete);
+
+      // Repository is deleted 1 second after the initial delay
+      fakeScheduledExecutor.advance(
+          TimeUnit.MINUTES.toSeconds(INITIAL_DELAY_MIN) + 1, TimeUnit.SECONDS);
+      assertThatRepositoryIsDeleted(repoToDelete);
+    }
+
+    try (FileRepository repoToDelete = createRepository(REPOSITORY_TO_DELETE)) {
+      // Repository recreated
+      assertThatRepositoryExists(repoToDelete);
+
+      // Repository is deleted again after the interval time
+      fakeScheduledExecutor.advance(TimeUnit.DAYS.toSeconds(INTERVAL_DAYS), TimeUnit.SECONDS);
+      assertThatRepositoryIsDeleted(repoToDelete);
+    }
+  }
+
+  private static void assertThatRepositoryIsDeleted(FileRepository repoToDelete) {
+    assertFalse(
+        "Repository " + repoToDelete.getDirectory() + " has not been deleted",
+        repoToDelete.getDirectory().exists());
+  }
+
+  private static void assertThatRepositoryExists(FileRepository repoToDelete) {
+    assertTrue(
+        "Repository " + repoToDelete.getDirectory() + " does not exist",
+        repoToDelete.getDirectory().exists());
   }
 
   @Test
   public void testStart() throws Exception {
-    FileRepository repoToDelete = createRepository("repo.1234567890123.deleted");
+    FileRepository repoToDelete = createRepository(REPOSITORY_TO_DELETE);
     FileRepository repoToKeep = createRepository("anotherRepo.git");
     trashFolders.start();
     trashFolders.getWorkerFuture().get();
-    assertThat(repoToDelete.getDirectory().exists()).isFalse();
-    assertThat(repoToKeep.getDirectory().exists()).isTrue();
+    assertThatRepositoryIsDeleted(repoToDelete);
+    assertThatRepositoryExists(repoToKeep);
   }
 
   private FileRepository createRepository(String repoName) throws IOException {
@@ -75,4 +142,16 @@
     repository.create(true);
     return (FileRepository) repository;
   }
+
+  private void setupTrashFolderCleanupSchedule(String startTime, String interval) {
+    cfg.setString("plugin", DELETE_PROJECT_PLUGIN, "deleteTrashFolderStartTime", startTime);
+    cfg.setString("plugin", DELETE_PROJECT_PLUGIN, "deleteTrashFolderInterval", interval);
+    Optional<ScheduleConfig.Schedule> schedule =
+        ScheduleConfig.builder(cfg, "plugin")
+            .setSubsection(DELETE_PROJECT_PLUGIN)
+            .setKeyStartTime("deleteTrashFolderStartTime")
+            .setKeyInterval("deleteTrashFolderInterval")
+            .buildSchedule();
+    when(pluginCfg.getSchedule()).thenReturn(schedule);
+  }
 }