Merge branch 'stable-3.4' into stable-3.5

* stable-3.4:
  Add an option to periodically warm the project_list cache

Release-Notes: skip
Change-Id: Id4a92ff775a0ccbad15ed54daf753a83bcba4adc
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 9d4de83..0ce5dae 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1313,6 +1313,21 @@
 +
 Default is the number of CPUs.
 
+[[cache.project_list.interval]]cache.project_list.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+the project_list cache warmer.
+
+By default, if `cache.project_list.maxAge` is set, `interval` will be set to
+half its value. If `cache.project_list.maxAge` is not set or `interval` is set
+to `-1`, it is disabled.
+
+[[cache.project_list.startTime]]cache.project_list.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+the project_list cache warmer.
+
+Default is 00:00 if the project_list cache warmer is enabled.
 
 [[capability]]
 === Section capability
diff --git a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
new file mode 100644
index 0000000..caffb45
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2022 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.project;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.time.Duration;
+import org.eclipse.jgit.lib.Config;
+
+public class PeriodicProjectListCacheWarmer implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class LifeCycle implements LifecycleListener {
+    protected final Config config;
+    protected final WorkQueue queue;
+    protected final PeriodicProjectListCacheWarmer runner;
+
+    @Inject
+    LifeCycle(
+        @GerritServerConfig Config config, WorkQueue queue, PeriodicProjectListCacheWarmer runner) {
+      this.config = config;
+      this.queue = queue;
+      this.runner = runner;
+    }
+
+    @Override
+    public void start() {
+      long interval = -1L;
+      String intervalString = config.getString("cache", ProjectCacheImpl.CACHE_LIST, "interval");
+      if (!"-1".equals(intervalString)) {
+        long maxAge =
+            config.getTimeUnit("cache", ProjectCacheImpl.CACHE_LIST, "maxAge", -1L, MILLISECONDS);
+        interval =
+            config.getTimeUnit(
+                "cache",
+                ProjectCacheImpl.CACHE_LIST,
+                "interval",
+                getHalfDuration(maxAge),
+                MILLISECONDS);
+      }
+
+      if (interval == -1L) {
+        logger.atWarning().log("project_list cache warmer is disabled");
+        return;
+      }
+
+      String startTime = config.getString("cache", ProjectCacheImpl.CACHE_LIST, "startTime");
+      if (startTime == null) {
+        startTime = "00:00";
+      }
+
+      runner.run();
+      queue.scheduleAtFixedRate(runner, ScheduleConfig.Schedule.createOrFail(interval, startTime));
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+
+    private long getHalfDuration(long duration) {
+      if (duration < 0) {
+        return duration;
+      }
+      return Duration.ofMillis(duration).dividedBy(2L).toMillis();
+    }
+  }
+
+  protected final ProjectCache cache;
+
+  @Inject
+  PeriodicProjectListCacheWarmer(ProjectCache cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public void run() {
+    logger.atFine().log("Loading project_list cache");
+    cache.all();
+    logger.atFine().log("Finished loading project_list cache");
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 7e7c7be..15b5b42 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -95,7 +95,7 @@
 
   public static final String PERSISTED_CACHE_NAME = "persisted_projects";
 
-  private static final String CACHE_LIST = "project_list";
+  public static final String CACHE_LIST = "project_list";
 
   public static Module module() {
     return new CacheModule() {
@@ -147,6 +147,13 @@
                 listener().to(ProjectCacheWarmer.class);
               }
             });
+        install(
+            new LifecycleModule() {
+              @Override
+              protected void configure() {
+                listener().to(PeriodicProjectListCacheWarmer.LifeCycle.class);
+              }
+            });
       }
     };
   }