Merge branch 'stable-3.11' into stable-3.12

* stable-3.11:
  Update base OS to AlmaLinux 9.3 and Java 17
  Get global-refdb.jar from Gerrit-CI
  Bump Gerrit to 3.9
  Add missing syslog-sidecar in docker-compose.yaml
  Removing obsolete version attribute in docker-compose.yaml
  Active/active setup using zookeeper-refdb plugin and zookeeper service

Change-Id: Id5629402d8aa08f0215d90dbd9dadf7573eaa579
diff --git a/README.md b/README.md
index c8ac4b4..fcf0566 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,10 @@
 # Gerrit high-availability plugin
 
-This plugin allows deploying a cluster of multiple Gerrit masters
+This plugin allows deploying a cluster of multiple Gerrit primaries
 on the same data-center sharing the same Git repositories.
 
-Requirements for the Gerrit masters are:
+Requirements for the Gerrit primaries are:
 
-- Gerrit v2.14.20 or later
 - Externally mounted filesystem shared among the cluster
 - Load-balancer (HAProxy or similar)
 
@@ -18,17 +17,17 @@
 
 Refer to the [build instructions in the plugin documentation](src/main/resources/Documentation/build.md).
 
-## Sample configuration for two Gerrit masters in high-availability
+## Sample configuration for two Gerrit primaries in high-availability
 
-Assuming that the Gerrit masters in the clusters are `gerrit-01.mycompany.com` and
+Assuming that the Gerrit primaries in the clusters are `gerrit-01.mycompany.com` and
 `gerrit-02.mycompany.com`, listening on the HTTP port 8080, with a shared volume
 mounted under `/shared`, see below the minimal configuration steps.
 
-1. Install one Gerrit master on the first node (e.g. `gerrit-01.mycompany.com`)
+1. Install one Gerrit primary on the first node (e.g. `gerrit-01.mycompany.com`)
    with the repositories location under the shared volume (e.g. `/shared/git`).
    Init the site in order to create the initial repositories.
 
-2. Copy all the files of the first Gerrit master onto the second node (e.g. `gerrit-02.mycompany.com`)
+2. Copy all the files of the first Gerrit primary onto the second node (e.g. `gerrit-02.mycompany.com`)
    so that it points to the same repositories location.
 
 3. Install the high-availability plugin into the `$GERRIT_SITE/plugins` directory of both
@@ -75,9 +74,9 @@
 
 ### Active-passive configuration
 
-It is the simplest and safest configuration, where only one Gerrit master at a
+It is the simplest and safest configuration, where only one Gerrit primary at a
 time serves the incoming requests.
-In case of failure of the primary master, the traffic is forwarded to the backup.
+In case of failure of the primary, the traffic is forwarded to the backup.
 
 Assuming a load-balancing implemented using [HAProxy](http://www.haproxy.org/)
 associated with the domain name `gerrit.mycompany.com`, exposing Gerrit cluster nodes
@@ -113,7 +112,7 @@
     server gerrit_http_01 gerrit-01.mycompany.com:8080 check inter 10s
     server gerrit_http_02 gerrit-01.mycompany.com:8080 check inter 10s backup
 
-ackend ssh
+backend gerrit_ssh_nodes
     mode tcp
     option httpchk GET /config/server/version HTTP/1.0
     http-check expect status 200
@@ -126,7 +125,7 @@
 
 ### Active-active configuration
 
-This is an evolution of the previous active-passive configuration, where only one Gerrit master at a
+This is an evolution of the previous active-passive configuration, where only one Gerrit primary at a
 time serves the HTTP write operations (PUT,POST,DELETE) while the remaining HTTP traffic is sent
 to both.
 In case of failure of one of the nodes, all the traffic is forwarded to the other node.
@@ -165,8 +164,8 @@
 
 ## Gerrit canonical URL and further adjustments
 
-Both Gerrit masters are now part of the same cluster, accessible through the HAProxy load-balancer.
-Set the `gerrit.canoncalWebUrl` on both Gerrit masters to the domain name of HAProxy so that any
+Both Gerrit primaries are now part of the same cluster, accessible through the HAProxy load-balancer.
+Set the `gerrit.canoncalWebUrl` on both Gerrit primaries to the domain name of HAProxy so that any
 location or URL generated by Gerrit would direct the traffic to the balancer and not to the instance
 that served the incoming call.
 
@@ -192,4 +191,4 @@
 ```
 [auth]
   cookiedomain = .mycompany.com
-```
\ No newline at end of file
+```
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
index a328694..921d750 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
@@ -33,7 +33,7 @@
 
   @Override
   protected void configureServlets() {
-    install(new RestForwarderServletModule());
+    install(new RestForwarderServletModule(config));
     if (config.healthCheck().enabled()) {
       install(new HealthServletModule());
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
index 6040220..6a90e73 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
@@ -22,6 +22,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarderModule;
 import com.ericsson.gerrit.plugins.highavailability.index.IndexModule;
 import com.ericsson.gerrit.plugins.highavailability.indexsync.IndexSyncModule;
+import com.ericsson.gerrit.plugins.highavailability.lock.FileBasedLockManager;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfoModule;
 import com.gerritforge.gerrit.globalrefdb.validation.ProjectDeletedSharedDbCleanup;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
@@ -46,6 +47,7 @@
   protected void configure() {
     install(new EnvModule());
     install(new ForwarderModule());
+    install(new FileBasedLockManager.Module());
 
     switch (config.main().transport()) {
       case HTTP:
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java
index b5c9730..374d327 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SetupLocalHAReplica.java
@@ -46,17 +46,17 @@
 class SetupLocalHAReplica {
   private static final String DATABASE = "database";
 
-  private final SitePaths masterSitePaths;
-  private final FileBasedConfig masterConfig;
+  private final SitePaths primarySitePaths;
+  private final FileBasedConfig primaryConfig;
   private final Path sharedDir;
 
   private SitePaths replicaSitePaths;
 
   @Inject
-  SetupLocalHAReplica(SitePaths masterSitePaths, InitFlags flags) {
-    this.masterSitePaths = masterSitePaths;
-    this.masterConfig = flags.cfg;
-    this.sharedDir = masterSitePaths.site_path.resolve(DEFAULT_SHARED_DIRECTORY);
+  SetupLocalHAReplica(SitePaths primarySitePaths, InitFlags flags) {
+    this.primarySitePaths = primarySitePaths;
+    this.primaryConfig = flags.cfg;
+    this.sharedDir = primarySitePaths.site_path.resolve(DEFAULT_SHARED_DIRECTORY);
   }
 
   void run(SitePaths replica, FileBasedConfig pluginConfig)
@@ -74,15 +74,15 @@
 
     mkdir(replicaSitePaths.logs_dir);
     mkdir(replicaSitePaths.tmp_dir);
-    symlink(Paths.get(masterConfig.getString("gerrit", null, "basePath")));
+    symlink(Paths.get(primaryConfig.getString("gerrit", null, "basePath")));
     symlink(sharedDir);
 
     FileBasedConfig replicaConfig =
         new FileBasedConfig(replicaSitePaths.gerrit_config.toFile(), FS.DETECTED);
     replicaConfig.load();
 
-    if ("h2".equals(masterConfig.getString(DATABASE, null, "type"))) {
-      masterConfig.setBoolean(DATABASE, "h2", "autoServer", true);
+    if ("h2".equals(primaryConfig.getString(DATABASE, null, "type"))) {
+      primaryConfig.setBoolean(DATABASE, "h2", "autoServer", true);
       replicaConfig.setBoolean(DATABASE, "h2", "autoServer", true);
       symlinkH2ReviewDbDir();
     }
@@ -91,32 +91,32 @@
   private List<Path> listDirsForCopy() throws IOException {
     ImmutableList.Builder<Path> toSkipBuilder = ImmutableList.builder();
     toSkipBuilder.add(
-        masterSitePaths.resolve(masterConfig.getString("gerrit", null, "basePath")),
-        masterSitePaths.db_dir,
-        masterSitePaths.logs_dir,
+        primarySitePaths.resolve(primaryConfig.getString("gerrit", null, "basePath")),
+        primarySitePaths.db_dir,
+        primarySitePaths.logs_dir,
         replicaSitePaths.site_path,
-        masterSitePaths.site_path.resolve(sharedDir),
-        masterSitePaths.tmp_dir);
-    if ("h2".equals(masterConfig.getString(DATABASE, null, "type"))) {
+        primarySitePaths.site_path.resolve(sharedDir),
+        primarySitePaths.tmp_dir);
+    if ("h2".equals(primaryConfig.getString(DATABASE, null, "type"))) {
       toSkipBuilder.add(
-          masterSitePaths.resolve(masterConfig.getString(DATABASE, null, DATABASE)).getParent());
+          primarySitePaths.resolve(primaryConfig.getString(DATABASE, null, DATABASE)).getParent());
     }
     final ImmutableList<Path> toSkip = toSkipBuilder.build();
 
     final ArrayList<Path> dirsForCopy = new ArrayList<>();
     Files.walkFileTree(
-        masterSitePaths.site_path,
+        primarySitePaths.site_path,
         EnumSet.of(FileVisitOption.FOLLOW_LINKS),
         Integer.MAX_VALUE,
         new SimpleFileVisitor<Path>() {
           @Override
           public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
               throws IOException {
-            if (Files.isSameFile(dir, masterSitePaths.site_path)) {
+            if (Files.isSameFile(dir, primarySitePaths.site_path)) {
               return FileVisitResult.CONTINUE;
             }
 
-            Path p = masterSitePaths.site_path.relativize(dir);
+            Path p = primarySitePaths.site_path.relativize(dir);
             if (shouldSkip(p)) {
               return FileVisitResult.SKIP_SUBTREE;
             }
@@ -125,7 +125,7 @@
           }
 
           private boolean shouldSkip(Path p) throws IOException {
-            Path resolved = masterSitePaths.site_path.resolve(p);
+            Path resolved = primarySitePaths.site_path.resolve(p);
             for (Path skip : toSkip) {
               if (skip.toFile().exists() && Files.isSameFile(resolved, skip)) {
                 return true;
@@ -139,7 +139,7 @@
   }
 
   private void copyFiles(Path dir) throws IOException {
-    final Path source = masterSitePaths.site_path.resolve(dir);
+    final Path source = primarySitePaths.site_path.resolve(dir);
     final Path target = replicaSitePaths.site_path.resolve(dir);
     Files.createDirectories(target);
     Files.walkFileTree(
@@ -167,12 +167,12 @@
     if (!path.isAbsolute()) {
       Files.createSymbolicLink(
           replicaSitePaths.site_path.resolve(path),
-          masterSitePaths.site_path.resolve(path).toAbsolutePath().normalize());
+          primarySitePaths.site_path.resolve(path).toAbsolutePath().normalize());
     }
   }
 
   private void symlinkH2ReviewDbDir() throws IOException {
-    symlink(Paths.get(masterConfig.getString(DATABASE, null, DATABASE)).getParent());
+    symlink(Paths.get(primaryConfig.getString(DATABASE, null, DATABASE)).getParent());
   }
 
   private void configureMainSection(FileBasedConfig pluginConfig) throws IOException {
@@ -180,7 +180,7 @@
         MAIN_SECTION,
         null,
         SHARED_DIRECTORY_KEY,
-        masterSitePaths.site_path.relativize(sharedDir).toString());
+        primarySitePaths.site_path.relativize(sharedDir).toString());
     pluginConfig.save();
   }
 
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/SharedDirectory.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SharedDirectory.java
index 29e2a6b..d0d7d25 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/SharedDirectory.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/SharedDirectory.java
@@ -20,7 +20,7 @@
 import java.lang.annotation.Retention;
 
 /**
- * {@link java.nio.file.Path} to a directory accessible from both master instances.
+ * {@link java.nio.file.Path} to a directory accessible from both primary instances.
  *
  * <p>Example of usage:
  *
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexModule.java
index d5e168d..6424494 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexModule.java
@@ -16,9 +16,7 @@
 
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.AbstractModule;
 
@@ -29,7 +27,5 @@
     DynamicSet.bind(binder(), LifecycleListener.class).to(AutoReindexScheduler.class);
     DynamicSet.bind(binder(), ChangeIndexedListener.class).to(IndexTs.class);
     DynamicSet.bind(binder(), AccountIndexedListener.class).to(IndexTs.class);
-    DynamicSet.bind(binder(), GroupIndexedListener.class).to(IndexTs.class);
-    DynamicSet.bind(binder(), ProjectIndexedListener.class).to(IndexTs.class);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexScheduler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexScheduler.java
index e948ba4..e18cc0d 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexScheduler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/AutoReindexScheduler.java
@@ -33,8 +33,6 @@
   private final Configuration.AutoReindex cfg;
   private final ChangeReindexRunnable changeReindex;
   private final AccountReindexRunnable accountReindex;
-  private final GroupReindexRunnable groupReindex;
-  private final ProjectReindexRunnable projectReindex;
   private final ScheduledExecutorService executor;
   private final List<Future<?>> futureTasks = new ArrayList<>();
 
@@ -43,14 +41,10 @@
       Configuration cfg,
       WorkQueue workQueue,
       ChangeReindexRunnable changeReindex,
-      AccountReindexRunnable accountReindex,
-      GroupReindexRunnable groupReindex,
-      ProjectReindexRunnable projectReindex) {
+      AccountReindexRunnable accountReindex) {
     this.cfg = cfg.autoReindex();
     this.changeReindex = changeReindex;
     this.accountReindex = accountReindex;
-    this.groupReindex = groupReindex;
-    this.projectReindex = projectReindex;
     this.executor = workQueue.createQueue(1, "HighAvailability-AutoReindex");
   }
 
@@ -59,8 +53,7 @@
     if (cfg.pollInterval().compareTo(Duration.ZERO) > 0) {
       log.atInfo().log(
           "Scheduling auto-reindex after %s and every %s", cfg.delay(), cfg.pollInterval());
-      for (Runnable reindexTask :
-          List.of(changeReindex, accountReindex, groupReindex, projectReindex)) {
+      for (Runnable reindexTask : List.of(changeReindex, accountReindex)) {
         futureTasks.add(
             executor.scheduleAtFixedRate(
                 reindexTask,
@@ -70,8 +63,7 @@
       }
     } else {
       log.atInfo().log("Scheduling auto-reindex after %s", cfg.delay());
-      for (Runnable reindexTask :
-          List.of(changeReindex, accountReindex, groupReindex, projectReindex)) {
+      for (Runnable reindexTask : List.of(changeReindex, accountReindex)) {
         futureTasks.add(executor.schedule(reindexTask, cfg.delay().toSeconds(), TimeUnit.SECONDS));
       }
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnable.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnable.java
deleted file mode 100644
index 05efe07..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnable.java
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright (C) 2018 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.ericsson.gerrit.plugins.highavailability.autoreindex;
-
-import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexGroupHandler;
-import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexingHandler.Operation;
-import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.AbstractIndexRestApiServlet;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.AccountGroupByIdAudit;
-import com.google.gerrit.entities.AccountGroupMemberAudit;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.db.Groups;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-
-public class GroupReindexRunnable extends ReindexRunnable<GroupReference> {
-
-  private static final FluentLogger log = FluentLogger.forEnclosingClass();
-
-  private final Groups groups;
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final ForwardedIndexGroupHandler indexer;
-
-  @Inject
-  public GroupReindexRunnable(
-      ForwardedIndexGroupHandler indexer,
-      IndexTs indexTs,
-      OneOffRequestContext ctx,
-      Groups groups,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers) {
-    super(AbstractIndexRestApiServlet.IndexName.GROUP, indexTs, ctx);
-    this.groups = groups;
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.indexer = indexer;
-  }
-
-  @Override
-  protected Iterable<GroupReference> fetchItems() throws Exception {
-    return groups.getAllGroupReferences()::iterator;
-  }
-
-  @Override
-  protected Optional<Timestamp> indexIfNeeded(GroupReference g, Timestamp sinceTs) {
-    try {
-      Optional<InternalGroup> internalGroup = groups.getGroup(g.getUUID());
-      if (internalGroup.isPresent()) {
-        InternalGroup group = internalGroup.get();
-        Timestamp groupCreationTs = Timestamp.from(group.getCreatedOn());
-
-        Repository allUsersRepo = repoManager.openRepository(allUsers);
-
-        List<AccountGroupByIdAudit> subGroupMembersAud =
-            groups.getSubgroupsAudit(allUsersRepo, g.getUUID());
-        Stream<Timestamp> groupIdAudAddedTs =
-            subGroupMembersAud.stream()
-                .map(accountGroupByIdAudit -> Timestamp.from(accountGroupByIdAudit.addedOn()))
-                .filter(Objects::nonNull);
-        Stream<Timestamp> groupIdAudRemovedTs =
-            subGroupMembersAud.stream()
-                .map(AccountGroupByIdAudit::removedOn)
-                .filter(Optional<Instant>::isPresent)
-                .map(inst -> Timestamp.from(inst.get()));
-        List<AccountGroupMemberAudit> groupMembersAud =
-            groups.getMembersAudit(allUsersRepo, g.getUUID());
-        Stream<Timestamp> groupMemberAudAddedTs =
-            groupMembersAud.stream()
-                .map(accountGroupByIdAudit -> Timestamp.from(accountGroupByIdAudit.addedOn()))
-                .filter(Objects::nonNull);
-        Stream<Timestamp> groupMemberAudRemovedTs =
-            groupMembersAud.stream()
-                .map(AccountGroupMemberAudit::removedOn)
-                .filter(Optional<Instant>::isPresent)
-                .map(inst -> Timestamp.from(inst.get()));
-
-        Optional<Timestamp> groupLastTs =
-            Streams.concat(
-                    groupIdAudAddedTs,
-                    groupIdAudRemovedTs,
-                    groupMemberAudAddedTs,
-                    groupMemberAudRemovedTs,
-                    Stream.of(groupCreationTs))
-                .max(Comparator.naturalOrder());
-
-        if (groupLastTs.isPresent() && groupLastTs.get().after(sinceTs)) {
-          log.atInfo().log("Index %s/%s/%s", g.getUUID(), g.getName(), groupLastTs.get());
-          indexer.index(g.getUUID(), Operation.INDEX, Optional.empty());
-          return groupLastTs;
-        }
-      }
-    } catch (IOException | ConfigInvalidException e) {
-      log.atSevere().withCause(e).log("Reindex failed");
-    }
-    return Optional.empty();
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/IndexTs.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/IndexTs.java
index d05c45d..75a9681 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/IndexTs.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/IndexTs.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.extensions.annotations.PluginData;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
-import com.google.gerrit.extensions.events.GroupIndexedListener;
-import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -40,11 +38,7 @@
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 @Singleton
-public class IndexTs
-    implements ChangeIndexedListener,
-        AccountIndexedListener,
-        GroupIndexedListener,
-        ProjectIndexedListener {
+public class IndexTs implements ChangeIndexedListener, AccountIndexedListener {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
   private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
 
@@ -52,15 +46,11 @@
   private final ScheduledExecutorService exec;
   private final FlusherRunner changeFlusher;
   private final FlusherRunner accountFlusher;
-  private final FlusherRunner groupFlusher;
-  private final FlusherRunner projectFlusher;
   private final ChangeFinder changeFinder;
   private final CurrentRequestContext currCtx;
 
   private volatile LocalDateTime changeTs;
   private volatile LocalDateTime accountTs;
-  private volatile LocalDateTime groupTs;
-  private volatile LocalDateTime projectTs;
 
   class FlusherRunner implements Runnable {
     private final AbstractIndexRestApiServlet.IndexName index;
@@ -91,12 +81,8 @@
       switch (index) {
         case CHANGE:
           return changeTs;
-        case GROUP:
-          return groupTs;
         case ACCOUNT:
           return accountTs;
-        case PROJECT:
-          return projectTs;
         default:
           throw new IllegalArgumentException("Unsupported index " + index);
       }
@@ -113,15 +99,9 @@
         case CHANGE:
           changeTs = newTs;
           break;
-        case GROUP:
-          groupTs = newTs;
-          break;
         case ACCOUNT:
           accountTs = newTs;
           break;
-        case PROJECT:
-          projectTs = newTs;
-          break;
       }
 
       tsPathSnapshot = FileSnapshot.save(tsPath.toFile());
@@ -138,23 +118,11 @@
     this.exec = queue.getDefaultQueue();
     this.changeFlusher = new FlusherRunner(AbstractIndexRestApiServlet.IndexName.CHANGE);
     this.accountFlusher = new FlusherRunner(AbstractIndexRestApiServlet.IndexName.ACCOUNT);
-    this.groupFlusher = new FlusherRunner(AbstractIndexRestApiServlet.IndexName.GROUP);
-    this.projectFlusher = new FlusherRunner(AbstractIndexRestApiServlet.IndexName.PROJECT);
     this.changeFinder = changeFinder;
     this.currCtx = currCtx;
   }
 
   @Override
-  public void onProjectIndexed(String project) {
-    currCtx.onlyWithContext((ctx) -> update(IndexName.PROJECT, LocalDateTime.now()));
-  }
-
-  @Override
-  public void onGroupIndexed(String uuid) {
-    currCtx.onlyWithContext((ctx) -> update(IndexName.GROUP, LocalDateTime.now()));
-  }
-
-  @Override
   public void onAccountIndexed(int id) {
     currCtx.onlyWithContext((ctx) -> update(IndexName.ACCOUNT, LocalDateTime.now()));
   }
@@ -205,14 +173,6 @@
         accountTs = dateTime;
         exec.execute(accountFlusher);
         break;
-      case GROUP:
-        groupTs = dateTime;
-        exec.execute(groupFlusher);
-        break;
-      case PROJECT:
-        projectTs = dateTime;
-        exec.execute(projectFlusher);
-        break;
       default:
         throw new IllegalArgumentException("Unsupported index " + index);
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/ProjectReindexRunnable.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/ProjectReindexRunnable.java
deleted file mode 100644
index 00c574d..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/ProjectReindexRunnable.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2018 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.ericsson.gerrit.plugins.highavailability.autoreindex;
-
-import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.AbstractIndexRestApiServlet;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import java.sql.Timestamp;
-import java.util.Optional;
-
-public class ProjectReindexRunnable extends ReindexRunnable<Project.NameKey> {
-
-  private final ProjectCache projectCache;
-
-  @Inject
-  public ProjectReindexRunnable(
-      IndexTs indexTs, OneOffRequestContext ctx, ProjectCache projectCache) {
-    super(AbstractIndexRestApiServlet.IndexName.PROJECT, indexTs, ctx);
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  protected Iterable<Project.NameKey> fetchItems() {
-    return projectCache.all();
-  }
-
-  @Override
-  protected Optional<Timestamp> indexIfNeeded(Project.NameKey g, Timestamp sinceTs) {
-    return Optional.empty();
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
index a787d3a..fd654ba 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
@@ -14,6 +14,8 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.ALL_CHANGES_FOR_PROJECT;
+
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeChecker;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl;
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutor;
@@ -119,19 +121,29 @@
   @Override
   protected CompletableFuture<Boolean> doDelete(String id, Optional<IndexEvent> indexEvent)
       throws IOException {
-    indexer.delete(parseChangeId(id));
-    log.atFine().log("Change %s successfully deleted from index", id);
+    if (ALL_CHANGES_FOR_PROJECT.equals(extractChangeId(id))) {
+      Project.NameKey projectName = parseProject(id);
+      indexer.deleteAllForProject(projectName);
+      log.atFine().log("All %s changes successfully deleted from index", projectName.get());
+    } else {
+      indexer.delete(parseChangeId(id));
+      log.atFine().log("Change %s successfully deleted from index", id);
+    }
     return CompletableFuture.completedFuture(true);
   }
 
   private static Change.Id parseChangeId(String id) {
-    return Change.id(Integer.parseInt(getChangeIdParts(id).get(1)));
+    return Change.id(Integer.parseInt(extractChangeId(id)));
   }
 
   private static Project.NameKey parseProject(String id) {
     return Project.nameKey(getChangeIdParts(id).get(0));
   }
 
+  private static String extractChangeId(String id) {
+    return getChangeIdParts(id).get(1);
+  }
+
   private static List<String> getChangeIdParts(String id) {
     return Splitter.on("~").splitToList(id);
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
index b73b676..3854ee3 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
@@ -14,14 +14,15 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import java.util.concurrent.CompletableFuture;
 
-/** Forward indexing, stream events and cache evictions to the other master */
+/** Forward indexing, stream events and cache evictions to the other primary */
 public interface Forwarder {
 
   /**
-   * Forward a account indexing event to the other master.
+   * Forward an account indexing event to the other primary.
    *
    * @param accountId the account to index.
    * @param indexEvent the details of the index event.
@@ -31,7 +32,7 @@
   CompletableFuture<Boolean> indexAccount(int accountId, IndexEvent indexEvent);
 
   /**
-   * Forward a change indexing event to the other master.
+   * Forward a change indexing event to the other primary.
    *
    * @param projectName the project of the change to index.
    * @param changeId the change to index.
@@ -42,7 +43,7 @@
   CompletableFuture<Boolean> indexChange(String projectName, int changeId, IndexEvent indexEvent);
 
   /**
-   * Forward a change indexing event to the other master using batch index endpoint.
+   * Forward a change indexing event to the other primary using batch index endpoint.
    *
    * @param projectName the project of the change to index.
    * @param changeId the change to index.
@@ -54,7 +55,7 @@
       String projectName, int changeId, IndexEvent indexEvent);
 
   /**
-   * Forward a delete change from index event to the other master.
+   * Forward a delete change from index event to the other primary.
    *
    * @param changeId the change to remove from the index.
    * @param indexEvent the details of the index event.
@@ -64,7 +65,7 @@
   CompletableFuture<Boolean> deleteChangeFromIndex(int changeId, IndexEvent indexEvent);
 
   /**
-   * Forward a group indexing event to the other master.
+   * Forward a group indexing event to the other primary.
    *
    * @param uuid the group to index.
    * @param indexEvent the details of the index event.
@@ -74,7 +75,7 @@
   CompletableFuture<Boolean> indexGroup(String uuid, IndexEvent indexEvent);
 
   /**
-   * Forward a project indexing event to the other master.
+   * Forward a project indexing event to the other primary.
    *
    * @param projectName the project to index.
    * @param indexEvent the details of the index event.
@@ -84,7 +85,7 @@
   CompletableFuture<Boolean> indexProject(String projectName, IndexEvent indexEvent);
 
   /**
-   * Forward a stream event to the other master.
+   * Forward a stream event to the other primary.
    *
    * @param event the event to forward.
    * @return {@link CompletableFuture} of true if successful, otherwise {@link CompletableFuture} of
@@ -93,7 +94,7 @@
   CompletableFuture<Boolean> send(Event event);
 
   /**
-   * Forward a cache eviction event to the other master.
+   * Forward a cache eviction event to the other primary.
    *
    * @param cacheName the name of the cache to evict an entry from.
    * @param key the key identifying the entry to evict from the cache.
@@ -103,7 +104,7 @@
   CompletableFuture<Boolean> evict(String cacheName, Object key);
 
   /**
-   * Forward an addition to the project list cache to the other master.
+   * Forward an addition to the project list cache to the other primary.
    *
    * @param projectName the name of the project to add to the project list cache
    * @return {@link CompletableFuture} of true if successful, otherwise {@link CompletableFuture} of
@@ -112,11 +113,20 @@
   CompletableFuture<Boolean> addToProjectList(String projectName);
 
   /**
-   * Forward a removal from the project list cache to the other master.
+   * Forward a removal from the project list cache to the other primary.
    *
    * @param projectName the name of the project to remove from the project list cache
    * @return {@link CompletableFuture} of true if successful, otherwise {@link CompletableFuture} of
    *     false.
    */
   CompletableFuture<Boolean> removeFromProjectList(String projectName);
+
+  /**
+   * Forward the removal of all project changes from index to the other primary.
+   *
+   * @param projectName the name of the project whose changes should be removed from the index
+   * @return {@link CompletableFuture} of true if successful, otherwise {@link CompletableFuture} of
+   *     false.
+   */
+  CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName);
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java
new file mode 100644
index 0000000..79933fb
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java
@@ -0,0 +1,32 @@
+// 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.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.gerrit.entities.Project;
+
+public class DeleteAllProjectChangesFromIndex extends Command {
+  static final String TYPE = "delete-all-project-changes-from-index";
+
+  private final Project.NameKey projectName;
+
+  protected DeleteAllProjectChangesFromIndex(Project.NameKey projectName) {
+    super(TYPE);
+    this.projectName = projectName;
+  }
+
+  public String getProjectName() {
+    return projectName.get();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
index 40a358e..9f1f179 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
@@ -19,6 +19,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
@@ -108,6 +109,11 @@
     return execute(new RemoveFromProjectList(projectName));
   }
 
+  @Override
+  public CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName) {
+    return execute(new DeleteAllProjectChangesFromIndex(projectName));
+  }
+
   private CompletableFuture<Boolean> execute(Command cmd) {
     return executor.getAsync(() -> executeOnce(cmd));
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
index 922fbd6..d53cd82 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
@@ -20,8 +20,10 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.HttpResponseHandler.HttpResult;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.events.Event;
@@ -37,12 +39,14 @@
 import org.apache.http.HttpException;
 import org.apache.http.client.ClientProtocolException;
 
-class RestForwarder implements Forwarder {
+public class RestForwarder implements Forwarder {
   enum RequestMethod {
     POST,
     DELETE
   }
 
+  public static final String ALL_CHANGES_FOR_PROJECT = "0";
+
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
   private final HttpSession httpSession;
@@ -115,6 +119,12 @@
     return escapedProjectName + '~' + changeId;
   }
 
+  @VisibleForTesting
+  public static String buildAllChangesForProjectEndpoint(String projectName) {
+    String escapedProjectName = Url.encode(projectName);
+    return escapedProjectName + '~' + ALL_CHANGES_FOR_PROJECT;
+  }
+
   @Override
   public CompletableFuture<Boolean> indexProject(String projectName, IndexEvent event) {
     return execute(
@@ -150,6 +160,15 @@
         Url.encode(projectName));
   }
 
+  @Override
+  public CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName) {
+    return execute(
+        RequestMethod.DELETE,
+        "Delete all project changes from index",
+        "index/change",
+        buildAllChangesForProjectEndpoint(projectName.get()));
+  }
+
   private static String buildProjectListEndpoint() {
     return Joiner.on("/").join("cache", Constants.PROJECT_LIST);
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
index ffe3afc..c58d8b2 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
@@ -14,20 +14,34 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.google.inject.servlet.ServletModule;
 
 public class RestForwarderServletModule extends ServletModule {
+  private final Configuration config;
+
+  public RestForwarderServletModule(Configuration config) {
+    this.config = config;
+  }
+
   @Override
   protected void configureServlets() {
-    serveRegex("/index/account/\\d+$").with(IndexAccountRestApiServlet.class);
-    serveRegex("/index/change/batch/.*$").with(IndexBatchChangeRestApiServlet.class);
-    serveRegex("/index/change/.*$").with(IndexChangeRestApiServlet.class);
-    serveRegex("/index/group/\\w+$").with(IndexGroupRestApiServlet.class);
-    serveRegex("/index/project/.*$").with(IndexProjectRestApiServlet.class);
-    serve("/event/*").with(EventRestApiServlet.class);
-    serve("/cache/project_list/*").with(ProjectListApiServlet.class);
-    serve("/cache/*").with(CacheRestApiServlet.class);
-
-    serve("/query/changes.updated.since/*").with(QueryChangesUpdatedSinceServlet.class);
+    if (config.index().synchronize()) {
+      serveRegex("/index/account/\\d+$").with(IndexAccountRestApiServlet.class);
+      serveRegex("/index/change/batch/.*$").with(IndexBatchChangeRestApiServlet.class);
+      serveRegex("/index/change/.*$").with(IndexChangeRestApiServlet.class);
+      serveRegex("/index/group/\\w+$").with(IndexGroupRestApiServlet.class);
+      serveRegex("/index/project/.*$").with(IndexProjectRestApiServlet.class);
+      if (config.indexSync().enabled()) {
+        serve("/query/changes.updated.since/*").with(QueryChangesUpdatedSinceServlet.class);
+      }
+    }
+    if (config.event().synchronize()) {
+      serve("/event/*").with(EventRestApiServlet.class);
+    }
+    if (config.cache().synchronize()) {
+      serve("/cache/project_list/*").with(ProjectListApiServlet.class);
+      serve("/cache/*").with(CacheRestApiServlet.class);
+    }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeChecker.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeChecker.java
index ef7f942..0ed6160 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeChecker.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeChecker.java
@@ -23,7 +23,7 @@
 public interface ChangeChecker {
 
   /**
-   * Return the Change notes read from ReviewDb or NoteDb.
+   * Return the Change notes read from NoteDb.
    *
    * @return notes of the Change
    */
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
index ec2a091..6475d99 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
@@ -18,6 +18,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
@@ -58,6 +59,17 @@
     currCtx.onlyWithContext((ctx) -> executeIndexChangeTask(projectName, id));
   }
 
+  @Override
+  public void onAllChangesDeletedForProject(String projectName) {
+    currCtx.onlyWithContext((ctx) -> executeAllChangesDeletedForProject(projectName));
+  }
+
+  private void executeAllChangesDeletedForProject(String projectName) {
+    if (!Context.isForwardedEvent()) {
+      forwarder.deleteAllChangesForProject(Project.nameKey(projectName));
+    }
+  }
+
   private void executeIndexChangeTask(String projectName, int id) {
     if (!Context.isForwardedEvent()) {
       String changeId = projectName + "~" + id;
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java
index 92834bd..69bc2e4 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java
@@ -47,6 +47,7 @@
 import dev.failsafe.function.CheckedSupplier;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -106,46 +107,51 @@
     if (peers.size() == 0) {
       return false;
     }
-
-    ChangeIndexer indexer = changeIndexerFactory.create(executor, changeIndexes, false);
-    // NOTE: this loop will stop as soon as the initial sync is performed from one peer
+    boolean failed = false;
+    Set<String> updatedChanges = new HashSet<>();
     for (PeerInfo peer : peers) {
-      if (syncFrom(peer, indexer)) {
-        log.atFine().log("Finished indexSync");
-        return true;
+      try {
+        updatedChanges.addAll(collectUpdatedChanges(peer));
+      } catch (IOException e) {
+        log.atSevere().withCause(e).log("Error while querying changes from %s", peer);
+        failed = true;
       }
     }
 
-    return false;
+    if (syncIndex(updatedChanges)) {
+      log.atFine().log("Finished indexSync");
+    } else {
+      log.atSevere().log("Failed to index out of sync changes");
+      failed = true;
+    }
+
+    return !failed;
   }
 
-  private boolean syncFrom(PeerInfo peer, ChangeIndexer indexer) {
-    log.atFine().log("Syncing index with %s", peer.getDirectUrl());
+  private List<String> collectUpdatedChanges(PeerInfo peer) throws IOException {
+    log.atFine().log("Collecting out of sync changes from %s", peer.getDirectUrl());
     String peerUrl = peer.getDirectUrl();
     String uri =
         Joiner.on("/").join(peerUrl, pluginRelativePath, "query/changes.updated.since", age);
     HttpGet queryRequest = new HttpGet(uri);
-    List<String> ids;
-    try {
-      log.atFine().log("Executing %s", queryRequest);
-      ids = httpClient.execute(queryRequest, queryChangesResponseHandler);
-    } catch (IOException e) {
-      log.atSevere().withCause(e).log("Error while querying changes from %s", uri);
-      return false;
-    }
+    log.atFine().log("Executing %s", queryRequest);
+    return httpClient.execute(queryRequest, queryChangesResponseHandler);
+  }
 
+  private boolean syncIndex(Set<String> updatedChanges) {
+    ChangeIndexer indexer = changeIndexerFactory.create(executor, changeIndexes, false);
     try {
-      List<ListenableFuture<Boolean>> indexingTasks = new ArrayList<>(ids.size());
-      for (String id : ids) {
+      List<ListenableFuture<Boolean>> indexingTasks = new ArrayList<>(updatedChanges.size());
+      for (String id : updatedChanges) {
         indexingTasks.add(indexAsync(id, indexer));
       }
       Futures.allAsList(indexingTasks).get();
     } catch (InterruptedException | ExecutionException e) {
-      log.atSevere().withCause(e).log("Error while reindexing %s", ids);
+      log.atSevere().withCause(e).log("Error while reindexing %s", updatedChanges);
       return false;
     }
 
-    syncChangeDeletions(ids, indexer);
+    syncChangeDeletions(updatedChanges, indexer);
 
     return true;
   }
@@ -165,7 +171,7 @@
     return indexer.asyncReindexIfStale(projectName, Change.id(changeNumber));
   }
 
-  private void syncChangeDeletions(List<String> theirChanges, ChangeIndexer indexer) {
+  private void syncChangeDeletions(Set<String> theirChanges, ChangeIndexer indexer) {
     Set<String> ourChanges = queryLocalIndex();
     for (String d : Sets.difference(ourChanges, ImmutableSet.copyOf(theirChanges))) {
       deleteIfMissingInNoteDb(d, indexer);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLock.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLock.java
new file mode 100644
index 0000000..c9af291
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLock.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import dev.failsafe.Failsafe;
+import dev.failsafe.RetryPolicy;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.FS.LockToken;
+
+public class FileBasedLock implements Lock {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    FileBasedLock create(String name);
+  }
+
+  private final TouchFileService touchFileService;
+  private final String content;
+  private final Path lockPath;
+
+  private volatile ScheduledFuture<?> touchTask;
+  private volatile LockToken lockToken;
+
+  @AssistedInject
+  public FileBasedLock(
+      @LocksDirectory Path locksDir, TouchFileService touchFileService, @Assisted String name) {
+    this.touchFileService = touchFileService;
+    LockFileFormat format = new LockFileFormat(name);
+    this.content = format.content();
+    this.lockPath = locksDir.resolve(format.fileName());
+  }
+
+  @Override
+  public void lock() {
+    try {
+      tryLock(Long.MAX_VALUE, TimeUnit.SECONDS);
+    } catch (InterruptedException e) {
+      logger.atSevere().withCause(e).log("Interrupted while trying to lock: %s", lockPath);
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public void lockInterruptibly() throws InterruptedException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean tryLock() {
+    try {
+      createLockFile();
+      touchTask = touchFileService.touchForever(lockPath.toFile());
+      return true;
+    } catch (IOException e) {
+      logger.atInfo().withCause(e).log("Couldn't create lock file: %s", lockPath);
+      return false;
+    }
+  }
+
+  @Override
+  public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
+    RetryPolicy<Object> retry =
+        RetryPolicy.builder()
+            .withMaxAttempts(-1)
+            .withBackoff(10, 1000, ChronoUnit.MILLIS)
+            .withMaxDuration(Duration.of(time, unit.toChronoUnit()))
+            .handleResult(false)
+            .build();
+    return Failsafe.with(retry).get(this::tryLock);
+  }
+
+  @VisibleForTesting
+  Path getLockPath() {
+    return lockPath;
+  }
+
+  @Override
+  public void unlock() {
+    try {
+      if (touchTask != null) {
+        touchTask.cancel(false);
+      }
+      Files.deleteIfExists(lockPath);
+      if (lockToken != null) {
+        lockToken.close();
+      }
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Couldn't delete lock file: %s", lockPath);
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public Condition newCondition() {
+    throw new UnsupportedOperationException();
+  }
+
+  private Path createLockFile() throws IOException {
+    File f = lockPath.toFile();
+    lockToken = FS.DETECTED.createNewFileAtomic(f);
+    if (!lockToken.isCreated()) {
+      throw new IOException("Couldn't create " + lockPath);
+    }
+    Files.write(lockPath, content.getBytes(StandardCharsets.UTF_8));
+    f.deleteOnExit();
+    return lockPath;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockManager.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockManager.java
new file mode 100644
index 0000000..dfadab0
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockManager.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import com.ericsson.gerrit.plugins.highavailability.SharedDirectory;
+import com.ericsson.gerrit.plugins.highavailability.lock.FileBasedLock.Factory;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.project.LockManager;
+import com.google.inject.Inject;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.locks.Lock;
+
+public class FileBasedLockManager implements LockManager {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicItem.bind(binder(), LockManager.class).to(FileBasedLockManager.class);
+      bind(TouchFileService.class).to(TouchFileServiceImpl.class);
+      install(
+          new FactoryModule() {
+            @Override
+            protected void configure() {
+              factory(FileBasedLock.Factory.class);
+            }
+          });
+      listener().to(StaleLockRemoval.class);
+    }
+
+    @Provides
+    @Singleton
+    @TouchFileExecutor
+    ScheduledExecutorService createTouchFileExecutor(WorkQueue workQueue) {
+      return workQueue.createQueue(2, "TouchFileService");
+    }
+
+    @Provides
+    @Singleton
+    @StaleLockRemovalExecutor
+    ScheduledExecutorService createStaleLockRemovalExecutor(WorkQueue workQueue) {
+      return workQueue.createQueue(1, "StaleLockRemoval");
+    }
+
+    @Provides
+    @Singleton
+    @LocksDirectory
+    Path getLocksDirectory(@SharedDirectory Path sharedDir) throws IOException {
+      Path locksDirPath = sharedDir.resolve("locks");
+      Files.createDirectories(locksDirPath);
+      return locksDirPath;
+    }
+
+    @Provides
+    @Singleton
+    @TouchFileInterval
+    Duration getTouchFileInterval() {
+      return Duration.ofSeconds(1);
+    }
+
+    @Provides
+    @Singleton
+    @StalenessCheckInterval
+    Duration getStalenessCheckInterval() {
+      return Duration.ofSeconds(2);
+    }
+
+    @Provides
+    @Singleton
+    @StalenessAge
+    Duration getStalenessAge() {
+      return Duration.ofSeconds(60);
+    }
+  }
+
+  private final Factory lockFactory;
+
+  @Inject
+  FileBasedLockManager(FileBasedLock.Factory lockFactory) {
+    this.lockFactory = lockFactory;
+  }
+
+  @Override
+  public Lock getLock(String name) {
+    logger.atInfo().log("FileBasedLockManager.lock(%s)", name);
+    return lockFactory.create(name);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LockFileFormat.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LockFileFormat.java
new file mode 100644
index 0000000..9eefd91
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LockFileFormat.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.hash.Hashing;
+import java.nio.charset.StandardCharsets;
+
+public class LockFileFormat {
+  private static final CharMatcher HEX_DIGIT_MATCHER = CharMatcher.anyOf("0123456789abcdef");
+
+  private final String lockName;
+
+  public static boolean isLockFileName(String fileName) {
+    return fileName.length() == 40 && HEX_DIGIT_MATCHER.matchesAllOf(fileName);
+  }
+
+  public LockFileFormat(String lockName) {
+    this.lockName = lockName;
+  }
+
+  public String fileName() {
+    return Hashing.sha1().hashString(content(), StandardCharsets.UTF_8).toString();
+  }
+
+  public String content() {
+    return lockName + "\n";
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LocksDirectory.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LocksDirectory.java
new file mode 100644
index 0000000..405ec5e
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/LocksDirectory.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface LocksDirectory {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemoval.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemoval.java
new file mode 100644
index 0000000..d0efeb2
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemoval.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static com.ericsson.gerrit.plugins.highavailability.lock.LockFileFormat.isLockFileName;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+public class StaleLockRemoval implements LifecycleListener, Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ScheduledExecutorService executor;
+  private final Duration checkInterval;
+  private final Duration stalenessAge;
+  private final Path locksDir;
+
+  @Inject
+  StaleLockRemoval(
+      @StaleLockRemovalExecutor ScheduledExecutorService executor,
+      @StalenessCheckInterval Duration checkInterval,
+      @StalenessAge Duration stalenessAge,
+      @LocksDirectory Path locksDir) {
+    this.executor = executor;
+    this.checkInterval = checkInterval;
+    this.stalenessAge = stalenessAge;
+    this.locksDir = locksDir;
+  }
+
+  @Override
+  public void start() {
+    logger.atFine().log(
+        "Scheduling StaleLockRemoval to run every %d seconds", checkInterval.getSeconds());
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.scheduleWithFixedDelay(
+            this, checkInterval.getSeconds(), checkInterval.getSeconds(), TimeUnit.SECONDS);
+    logger.atFine().log(
+        "Scheduled StaleLockRemoval to run every %d seconds", checkInterval.getSeconds());
+  }
+
+  @Override
+  public void run() {
+    try (Stream<Path> stream = Files.walk(locksDir)) {
+      stream
+          .filter(Files::isRegularFile)
+          .filter(p -> isLockFileName(p.getFileName().toString()))
+          .forEach(this::removeIfStale);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Error while performing stale lock detection and removal");
+    }
+  }
+
+  private void removeIfStale(Path lockPath) {
+    logger.atFine().log("Inspecting %s", lockPath);
+    Instant now = Instant.now();
+    Instant lastModified = Instant.ofEpochMilli(lockPath.toFile().lastModified());
+    if (Duration.between(lastModified, now).compareTo(stalenessAge) > 0) {
+      logger.atInfo().log("Detected stale lock %s", lockPath);
+      try {
+        if (Files.deleteIfExists(lockPath)) {
+          logger.atInfo().log("Stale lock %s removed", lockPath);
+        } else {
+          logger.atInfo().log("Stale lock %s was removed by another thread", lockPath);
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Couldn't delete stale lock %s", lockPath);
+      }
+    }
+  }
+
+  @Override
+  public void stop() {}
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalExecutor.java
new file mode 100644
index 0000000..eba21e8
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface StaleLockRemovalExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessAge.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessAge.java
new file mode 100644
index 0000000..900551b
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessAge.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface StalenessAge {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessCheckInterval.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessCheckInterval.java
new file mode 100644
index 0000000..0c5a67f
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/StalenessCheckInterval.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface StalenessCheckInterval {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileExecutor.java
new file mode 100644
index 0000000..70a6939
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface TouchFileExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileInterval.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileInterval.java
new file mode 100644
index 0000000..08b1aaa
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileInterval.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface TouchFileInterval {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileService.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileService.java
new file mode 100644
index 0000000..9cf5b33
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileService.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import java.io.File;
+import java.util.concurrent.ScheduledFuture;
+
+public interface TouchFileService {
+  ScheduledFuture<?> touchForever(File file);
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceImpl.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceImpl.java
new file mode 100644
index 0000000..3093de9
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceImpl.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class TouchFileServiceImpl implements TouchFileService {
+
+  private final ScheduledExecutorService executor;
+  private final Duration interval;
+
+  @Inject
+  public TouchFileServiceImpl(
+      @TouchFileExecutor ScheduledExecutorService executor, @TouchFileInterval Duration interval) {
+    this.executor = executor;
+    this.interval = interval;
+  }
+
+  @Override
+  public ScheduledFuture<?> touchForever(File file) {
+    return executor.scheduleAtFixedRate(
+        () -> touch(file), interval.getSeconds(), interval.getSeconds(), TimeUnit.SECONDS);
+  }
+
+  private static void touch(File f) {
+    boolean succeeded = f.setLastModified(System.currentTimeMillis());
+    if (!succeeded) {
+      if (!f.exists()) {
+        throw new RuntimeException(
+            String.format("File %s doesn't exist, stopping the touch task", f.toPath()));
+      }
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index 80117e2..6fcd2e2 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -180,7 +180,6 @@
 sync with the primary, for example, after a node being offline for a long time.
 
 The HA plugin keeps the last update timestamp for each index in the following files:
-* `<gerrit_home>/data/high-availability/group`
 * `<gerrit_home>/data/high-availability/account`
 * `<gerrit_home>/data/high-availability/change`
 
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 67d7f6c..b089091 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -88,26 +88,44 @@
 
 ```autoReindex.enabled```
 :   Enable the tracking of the latest change indexed under data/high-availability
-    for each of the indexes. At startup scans all the changes, accounts and groups
-    and reindex the ones that have been updated by other nodes while the server was down.
+    for each of the indexes. At startup scans all the changes and accounts and reindex
+    the ones that have been updated by other nodes while the server was down.
     When not specified, the default is "false", that means no automatic tracking
     and indexing at start.
 
 ```autoReindex.delay```
 :   When autoReindex is enabled, indicates the delay aftere the plugin startup,
-    before triggering the conditional reindexing of all changes, accounts and groups.
+    before triggering the conditional reindexing of all changes and accounts.
     Delay is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
     When not specified, the default is "10 seconds".
 
 ```autoReindex.pollInterval```
 :   When autoReindex is enabled, indicates the interval between the conditional
-    reindexing of all changes, accounts and groups.
+    reindexing of all changes and accounts.
     Delay is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
     When not specified, polling of conditional reindexing is disabled.
 
 **NOTE:** The indexSync feature exposes a REST endpoint that can be used to discover project names.
 Admins are advised to restrict access to the REST endpoints exposed by this plugin.
 
+**Note:** For projects and groups reindexing, [scheduled indexer](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#scheduledIndexer) can be enabled with specific configurations.
+Example configurations:
+
+```
+[scheduledIndexer "groups"]
+    enabled = true
+    interval = 1h
+    startTime = 13:00
+
+[scheduledIndexer "projects"]
+    enabled = true
+    interval = 1h
+    startTime = 13:00
+```
+Note: Ensure these settings are added to enable periodic reindexing of groups and projects.
+Groups and projects may become outdated if indexing events are missed due to the node being down or
+some networking issues.
+
 ```indexSync.enabled```
 :   When indexSync is enabled, the primary servers will synchronize indexes with the intention to
     self-heal any missed reindexing event.
diff --git a/src/test/README.md b/src/test/README.md
index 16e4df9..80fe2e4 100644
--- a/src/test/README.md
+++ b/src/test/README.md
@@ -1,7 +1,7 @@
 # Gerrit high-availability docker setup example
 
 The Docker Compose project in the docker directory contains a simple test
-environment of two Gerrit masters in HA configuration, with their git repos
+environment of two Gerrit primaries in HA configuration, with their git repos
 hosted on NFS filesystem.
 
 ## How to build
diff --git a/src/test/docker/gerrit/Dockerfile b/src/test/docker/gerrit/Dockerfile
index f082d2f..02c3af1 100644
--- a/src/test/docker/gerrit/Dockerfile
+++ b/src/test/docker/gerrit/Dockerfile
@@ -11,7 +11,7 @@
     nfs-utils \
     && yum -y clean all
 
-ENV GERRIT_BRANCH stable-3.11
+ENV GERRIT_BRANCH master
 
 # Add gerrit user
 RUN adduser -p -m --uid 1000 gerrit --home-dir /home/gerrit
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnableTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnableTest.java
deleted file mode 100644
index 0513d42..0000000
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/autoreindex/GroupReindexRunnableTest.java
+++ /dev/null
@@ -1,209 +0,0 @@
-// Copyright (C) 2021 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.ericsson.gerrit.plugins.highavailability.autoreindex;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexGroupHandler;
-import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexingHandler.Operation;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Account.Id;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.AccountGroup.UUID;
-import com.google.gerrit.entities.AccountGroupByIdAudit;
-import com.google.gerrit.entities.AccountGroupMemberAudit;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.db.Groups;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import java.sql.Timestamp;
-import java.time.LocalDateTime;
-import java.util.Collections;
-import java.util.Optional;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.junit.MockitoJUnitRunner;
-
-@RunWith(MockitoJUnitRunner.class)
-public class GroupReindexRunnableTest {
-
-  @Mock private ForwardedIndexGroupHandler indexer;
-  @Mock private IndexTs indexTs;
-  @Mock private OneOffRequestContext ctx;
-  @Mock private Groups groups;
-  @Mock private GitRepositoryManager repoManager;
-  @Mock private AllUsersName allUsers;
-  private GroupReference groupReference;
-
-  private GroupReindexRunnable groupReindexRunnable;
-  private static UUID uuid;
-
-  @Before
-  public void setUp() throws Exception {
-    groupReindexRunnable =
-        new GroupReindexRunnable(indexer, indexTs, ctx, groups, repoManager, allUsers);
-    uuid = UUID.parse("123");
-    groupReference = GroupReference.create(uuid, "123");
-  }
-
-  @Test
-  public void groupIsIndexedWhenItIsCreatedAfterLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp afterCurrentTime = new Timestamp(currentTime.getTime() + 1000L);
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(afterCurrentTime));
-
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isTrue();
-    assertThat(groupLastTs.get()).isEqualTo(afterCurrentTime);
-    verify(indexer).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  @Test
-  public void groupIsNotIndexedWhenItIsCreatedBeforeLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp beforeCurrentTime = new Timestamp(currentTime.getTime() - 1000L);
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(beforeCurrentTime));
-
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isFalse();
-    verify(indexer, never()).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  @Test
-  public void groupIsNotIndexedGroupReferenceNotPresent() {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isFalse();
-  }
-
-  @Test
-  public void groupIsIndexedWhenNewUserAddedAfterLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp afterCurrentTime = new Timestamp(currentTime.getTime() + 1000L);
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(currentTime));
-    when(groups.getMembersAudit(any(), any()))
-        .thenReturn(
-            Collections.singletonList(
-                AccountGroupMemberAudit.builder()
-                    .addedBy(Account.Id.tryParse("1").get())
-                    .addedOn(afterCurrentTime.toInstant())
-                    .memberId(Account.Id.tryParse("1").get())
-                    .groupId(AccountGroup.Id.parse("1"))
-                    .build()));
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isTrue();
-    assertThat(groupLastTs.get()).isEqualTo(afterCurrentTime);
-    verify(indexer).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  @Test
-  public void groupIsIndexedWhenUserRemovedAfterLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp afterCurrentTime = new Timestamp(currentTime.getTime() + 1000L);
-    Timestamp beforeCurrentTime = new Timestamp(currentTime.getTime() - 1000L);
-
-    AccountGroupMemberAudit accountGroupMemberAudit =
-        AccountGroupMemberAudit.builder()
-            .addedBy(Account.Id.tryParse("1").get())
-            .addedOn(beforeCurrentTime.toInstant())
-            .memberId(Account.Id.tryParse("1").get())
-            .groupId(AccountGroup.Id.parse("2"))
-            .removed(Account.Id.tryParse("2").get(), afterCurrentTime.toInstant())
-            .build();
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(currentTime));
-    when(groups.getMembersAudit(any(), any()))
-        .thenReturn(Collections.singletonList(accountGroupMemberAudit));
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isTrue();
-    assertThat(groupLastTs.get()).isEqualTo(afterCurrentTime);
-    verify(indexer).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  @Test
-  public void groupIsIndexedWhenItIsSubGroupAddedAfterLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp afterCurrentTime = new Timestamp(currentTime.getTime() + 1000L);
-    Timestamp beforeCurrentTime = new Timestamp(currentTime.getTime() - 1000L);
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(beforeCurrentTime));
-    when(groups.getSubgroupsAudit(any(), any()))
-        .thenReturn(
-            Collections.singletonList(
-                AccountGroupByIdAudit.builder()
-                    .groupId(AccountGroup.Id.parse("1"))
-                    .includeUuid(UUID.parse("123"))
-                    .addedBy(Account.Id.tryParse("1").get())
-                    .addedOn(afterCurrentTime.toInstant())
-                    .build()));
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isTrue();
-    assertThat(groupLastTs.get()).isEqualTo(afterCurrentTime);
-    verify(indexer).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  @Test
-  public void groupIsIndexedWhenItIsSubGroupRemovedAfterLastGroupReindex() throws Exception {
-    Timestamp currentTime = Timestamp.valueOf(LocalDateTime.now());
-    Timestamp afterCurrentTime = new Timestamp(currentTime.getTime() + 1000L);
-    Timestamp beforeCurrentTime = new Timestamp(currentTime.getTime() - 1000L);
-
-    AccountGroupByIdAudit accountGroupByIdAud =
-        AccountGroupByIdAudit.builder()
-            .groupId(AccountGroup.Id.parse("1"))
-            .includeUuid(UUID.parse("123"))
-            .addedBy(Account.Id.tryParse("1").get())
-            .addedOn(beforeCurrentTime.toInstant())
-            .removed(Account.Id.tryParse("2").get(), afterCurrentTime.toInstant())
-            .build();
-
-    when(groups.getGroup(uuid)).thenReturn(getInternalGroup(beforeCurrentTime));
-    when(groups.getSubgroupsAudit(any(), any()))
-        .thenReturn(Collections.singletonList(accountGroupByIdAud));
-
-    Optional<Timestamp> groupLastTs =
-        groupReindexRunnable.indexIfNeeded(groupReference, currentTime);
-    assertThat(groupLastTs.isPresent()).isTrue();
-    assertThat(groupLastTs.get()).isEqualTo(afterCurrentTime);
-    verify(indexer).index(uuid, Operation.INDEX, Optional.empty());
-  }
-
-  private Optional<InternalGroup> getInternalGroup(Timestamp timestamp) {
-    return Optional.ofNullable(
-        InternalGroup.builder()
-            .setId(AccountGroup.Id.parse("1"))
-            .setNameKey(AccountGroup.nameKey("Test"))
-            .setOwnerGroupUUID(uuid)
-            .setVisibleToAll(true)
-            .setGroupUUID(UUID.parse("12"))
-            .setCreatedOn(timestamp.toInstant())
-            .setMembers(ImmutableSet.<Id>builder().build())
-            .setSubgroups(ImmutableSet.<UUID>builder().build())
-            .build());
-  }
-}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
index cc6939c..6a3a348 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.buildAllChangesForProjectEndpoint;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -54,6 +55,7 @@
 
   private static final int TEST_CHANGE_NUMBER = 123;
   private static String TEST_PROJECT = "test/project";
+  private static final String TEST_PROJECT_ENCODED = "test%2Fproject";
   private static String TEST_CHANGE_ID = TEST_PROJECT + "~" + TEST_CHANGE_NUMBER;
   private static final boolean CHANGE_EXISTS = true;
   private static final boolean CHANGE_DOES_NOT_EXIST = false;
@@ -112,6 +114,14 @@
   }
 
   @Test
+  public void AllChangesAreDeletedFromIndex() throws Exception {
+    handler
+        .index(buildAllChangesForProjectEndpoint(TEST_PROJECT), Operation.DELETE, Optional.empty())
+        .get(10, SECONDS);
+    verify(indexerMock, times(1)).deleteAllForProject(Project.nameKey(TEST_PROJECT_ENCODED));
+  }
+
+  @Test
   public void changeToIndexDoesNotExist() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_DOES_NOT_EXIST, CHANGE_OUTDATED);
     handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
index 8a2745e..4b98fe1 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.buildAllChangesForProjectEndpoint;
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -32,6 +33,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gson.Gson;
@@ -80,6 +82,14 @@
               PROJECT_NAME_URL_END + "~" + CHANGE_NUMBER);
   private static final String DELETE_CHANGE_ENDPOINT =
       Joiner.on("/").join(URL, PLUGINS, PLUGIN_NAME, "index/change", "~" + CHANGE_NUMBER);
+  private static final String DELETE_ALL_CHANGES_ENDPOINT =
+      Joiner.on("/")
+          .join(
+              URL,
+              PLUGINS,
+              PLUGIN_NAME,
+              "index/change",
+              buildAllChangesForProjectEndpoint(PROJECT_NAME));
   private static final int ACCOUNT_NUMBER = 2;
   private static final String INDEX_ACCOUNT_ENDPOINT =
       Joiner.on("/").join(URL, PLUGINS, PLUGIN_NAME, "index/account", ACCOUNT_NUMBER);
@@ -246,6 +256,17 @@
   }
 
   @Test
+  public void testAllChangesDeletedFromIndexOK() throws Exception {
+    when(httpSessionMock.delete(eq(DELETE_ALL_CHANGES_ENDPOINT)))
+        .thenReturn(new HttpResult(SUCCESSFUL, EMPTY_MSG));
+    assertThat(
+            forwarder
+                .deleteAllChangesForProject(Project.nameKey(PROJECT_NAME))
+                .get(TEST_TIMEOUT, TEST_TIMEOUT_UNITS))
+        .isTrue();
+  }
+
+  @Test
   public void testChangeDeletedFromIndexFailed() throws Exception {
     when(httpSessionMock.delete(eq(DELETE_CHANGE_ENDPOINT)))
         .thenReturn(new HttpResult(FAILED, EMPTY_MSG));
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/AbstractIndexForwardingIT.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/AbstractIndexForwardingIT.java
index 83f86d0..fe6cfce 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/AbstractIndexForwardingIT.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/AbstractIndexForwardingIT.java
@@ -49,10 +49,8 @@
     sysModule = "com.ericsson.gerrit.plugins.highavailability.Module",
     httpModule = "com.ericsson.gerrit.plugins.highavailability.HttpModule")
 public abstract class AbstractIndexForwardingIT extends LightweightPluginDaemonTest {
-  private static final int PORT = 18889;
-  private static final String URL = "http://localhost:" + PORT;
 
-  @Rule public WireMockRule wireMockRule = new WireMockRule(options().port(PORT));
+  @Rule public WireMockRule wireMockRule = new WireMockRule(options().dynamicPort());
 
   @Inject SitePaths sitePaths;
 
@@ -62,7 +60,7 @@
     FileBasedConfig fileBasedConfig =
         new FileBasedConfig(
             sitePaths.etc_dir.resolve(Configuration.PLUGIN_CONFIG_FILE).toFile(), FS.DETECTED);
-    fileBasedConfig.setString("peerInfo", "static", "url", URL);
+    fileBasedConfig.setString("peerInfo", "static", "url", url());
     fileBasedConfig.setInt("http", null, "retryInterval", 100);
     fileBasedConfig.save();
     beforeAction();
@@ -88,6 +86,10 @@
     verify(postRequestedFor(urlEqualTo(expectedRequest)));
   }
 
+  private String url() {
+    return "http://localhost:" + wireMockRule.port();
+  }
+
   /** Perform pre-test setup. */
   protected abstract void beforeAction() throws Exception;
 
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockTest.java
new file mode 100644
index 0000000..cf9dd28
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/FileBasedLockTest.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_CORE_SECTION;
+import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_KEY_SUPPORTSATOMICFILECREATION;
+
+import com.google.gerrit.server.util.git.DelegateSystemReader;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class FileBasedLockTest {
+
+  private static SystemReader setFakeSystemReader(FileBasedConfig cfg) {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new DelegateSystemReader(oldSystemReader) {
+          @Override
+          public FileBasedConfig openUserConfig(Config parent, FS fs) {
+            return cfg;
+          }
+        });
+    return oldSystemReader;
+  }
+
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private final Duration touchInterval = Duration.ofSeconds(1);
+  private final String lockName = "mylock";
+
+  private ScheduledExecutorService touchFileExecutor;
+  private TouchFileService touchFileService;
+  private FileBasedLock lock;
+
+  private FileBasedConfig cfg;
+
+  @Parameter public boolean supportsAtomicFileCreation;
+
+  @Parameters(name = "supportsAtomicFileCreation={0}")
+  public static Boolean[] testData() {
+    return new Boolean[] {true, false};
+  }
+
+  SystemReader oldSystemReader;
+
+  @Before
+  public void setUp() throws IOException {
+    touchFileExecutor = Executors.newScheduledThreadPool(2);
+    touchFileService = new TouchFileServiceImpl(touchFileExecutor, touchInterval);
+    Path locksDir = Path.of(tempFolder.newFolder().getPath());
+    lock = new FileBasedLock(locksDir, touchFileService, lockName);
+
+    File cfgFile = tempFolder.newFile(".gitconfig");
+    cfg = new FileBasedConfig(cfgFile, FS.DETECTED);
+    cfg.setBoolean(
+        CONFIG_CORE_SECTION,
+        null,
+        CONFIG_KEY_SUPPORTSATOMICFILECREATION,
+        supportsAtomicFileCreation);
+    cfg.save();
+    oldSystemReader = setFakeSystemReader(cfg);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    touchFileExecutor.shutdown();
+    touchFileExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+    SystemReader.setInstance(oldSystemReader);
+  }
+
+  @Test
+  public void lockCreatesFile_unlockDeletesFile() {
+    Path lockPath = lock.getLockPath();
+
+    assertThat(Files.exists(lockPath)).isFalse();
+
+    lock.lock();
+    assertThat(Files.exists(lockPath)).isTrue();
+
+    lock.unlock();
+    assertThat(Files.exists(lockPath)).isFalse();
+  }
+
+  @Test
+  public void testLockFileNameAndContent() throws IOException {
+    lock.lock();
+    Path lockPath = lock.getLockPath();
+
+    String content = Files.readString(lockPath, StandardCharsets.UTF_8);
+    assertThat(content).endsWith("\n");
+    LockFileFormat lockFileFormat = new LockFileFormat(content.substring(0, content.length() - 1));
+    assertThat(content).isEqualTo(lockFileFormat.content());
+    assertThat(lockPath.getFileName().toString()).isEqualTo(lockFileFormat.fileName());
+  }
+
+  @Test
+  public void tryLockAfterLock_fail() {
+    lock.lock();
+    assertThat(lock.tryLock()).isFalse();
+  }
+
+  @Test
+  public void tryLockWithTimeout_failsAfterTimeout() throws InterruptedException {
+    lock.lock();
+    assertThat(lock.tryLock(1, TimeUnit.SECONDS)).isFalse();
+  }
+
+  @Test
+  public void concurrentTryLock_exactlyOneSucceeds()
+      throws InterruptedException, ExecutionException {
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+
+    for (int i = 0; i < 10; i++) {
+      Future<Boolean> r1 = executor.submit(() -> lock.tryLock());
+      Future<Boolean> r2 = executor.submit(() -> lock.tryLock());
+      assertThat(r1.get()).isNotEqualTo(r2.get());
+      lock.unlock();
+    }
+
+    executor.shutdown();
+    executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+  }
+
+  @Test
+  public void tryLockWithTimeout_succeedsIfLockReleased() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(1);
+
+    lock.lock();
+
+    Duration timeout = Duration.ofSeconds(1);
+    Future<Boolean> r =
+        executor.submit(() -> lock.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS));
+
+    Thread.sleep(timeout.dividedBy(2).toMillis());
+
+    lock.unlock();
+
+    assertThat(r.get()).isTrue();
+
+    executor.shutdown();
+    executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+  }
+
+  @Test
+  public void tryLockWithTimeout_failsIfLockNotReleased() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(1);
+
+    lock.lock();
+    Future<Boolean> r = executor.submit(() -> lock.tryLock(1, TimeUnit.SECONDS));
+
+    assertThat(r.get()).isFalse();
+
+    executor.shutdown();
+    executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+  }
+
+  @Test
+  public void liveLock_lastUpdatedKeepsIncreasing() throws Exception {
+    ExecutorService executor = Executors.newFixedThreadPool(1);
+    CountDownLatch lockAcquired = new CountDownLatch(1);
+    CountDownLatch testDone = new CountDownLatch(1);
+
+    executor.submit(() -> acquireAndReleaseLock(lockAcquired, testDone));
+    lockAcquired.await();
+
+    File lockFile = lock.getLockPath().toFile();
+    long start = lockFile.lastModified();
+    long previous = start;
+    long last = start;
+    for (int i = 0; i < 3; i++) {
+      Thread.sleep(touchInterval.toMillis());
+      long current = lockFile.lastModified();
+      assertThat(current).isAtLeast(previous);
+      last = current;
+    }
+    assertThat(last).isGreaterThan(start);
+
+    testDone.countDown();
+
+    executor.shutdown();
+    executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+  }
+
+  private void acquireAndReleaseLock(CountDownLatch lockAcquired, CountDownLatch testDone) {
+    lock.lock();
+    lockAcquired.countDown();
+    try {
+      testDone.await();
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    } finally {
+      lock.unlock();
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalTest.java
new file mode 100644
index 0000000..957777c
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/StaleLockRemovalTest.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+
+public class StaleLockRemovalTest {
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private ScheduledExecutorService executor;
+  private StaleLockRemoval staleLockRemoval;
+  private Path locksDir;
+  private Duration stalenessAge;
+
+  @Before
+  public void setUp() throws IOException {
+    executor = Mockito.mock(ScheduledExecutorService.class);
+    executor = new ScheduledThreadPoolExecutor(2);
+    locksDir = tempFolder.newFolder().toPath();
+    stalenessAge = Duration.ofSeconds(3);
+    staleLockRemoval =
+        new StaleLockRemoval(executor, Duration.ofSeconds(1), stalenessAge, locksDir);
+  }
+
+  @Test
+  public void staleLockRemoved() throws Exception {
+    Path lockPath = createLockFile("foo");
+    Thread.sleep(stalenessAge.toMillis());
+    assertFilesExist(lockPath);
+    staleLockRemoval.run();
+    assertFilesDoNotExist(lockPath);
+  }
+
+  @Test
+  public void nonStaleLockNotRemoved() throws Exception {
+    Path lockPath = createLockFile("foo");
+    staleLockRemoval.run();
+    assertFilesExist(lockPath);
+  }
+
+  @Test
+  public void nonLockFilesNotRemoved() throws Exception {
+    Path nonLock = Files.createFile(locksDir.resolve("nonLock"));
+    Thread.sleep(stalenessAge.toMillis());
+    staleLockRemoval.run();
+    assertFilesExist(nonLock);
+  }
+
+  @Test
+  public void multipleLocksHandledProperly() throws Exception {
+    Path stale1 = createLockFile("stale-1");
+    Path stale2 = createLockFile("stale-2");
+    Path stale3 = createLockFile("stale-3");
+
+    Thread.sleep(stalenessAge.toMillis());
+
+    Path live1 = createLockFile("live-1");
+    Path live2 = createLockFile("live-2");
+    Path live3 = createLockFile("live-3");
+
+    staleLockRemoval.run();
+    assertFilesDoNotExist(stale1, stale2, stale3);
+    assertFilesExist(live1, live2, live3);
+  }
+
+  private Path createLockFile(String name) throws IOException {
+    return Files.createFile(locksDir.resolve(new LockFileFormat(name).fileName()));
+  }
+
+  private void assertFilesExist(Path... paths) {
+    for (Path p : paths) {
+      assertThat(Files.exists(p)).isTrue();
+    }
+  }
+
+  private void assertFilesDoNotExist(Path... paths) {
+    for (Path p : paths) {
+      assertThat(Files.exists(p)).isFalse();
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceTest.java
new file mode 100644
index 0000000..064eef1
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/lock/TouchFileServiceTest.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2024 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.ericsson.gerrit.plugins.highavailability.lock;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class TouchFileServiceTest {
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private ScheduledThreadPoolExecutor executor;
+  private TouchFileService service;
+  private Path locksDir;
+  private Duration touchFileInterval;
+
+  @Before
+  public void setUp() throws IOException {
+    executor = new ScheduledThreadPoolExecutor(2);
+    touchFileInterval = Duration.ofSeconds(1);
+    service = new TouchFileServiceImpl(executor, touchFileInterval);
+    locksDir = tempFolder.newFolder().toPath();
+  }
+
+  @After
+  public void tearDown() throws InterruptedException {
+    executor.shutdown();
+    executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
+  }
+
+  @Test
+  public void touchServiceIncreasesLastModified() throws Exception {
+    File f = Files.createFile(locksDir.resolve("foo")).toFile();
+    service.touchForever(f);
+    verifyLastUpdatedIncreases(f);
+  }
+
+  @Test
+  public void touchTaskCancelation() throws Exception {
+    File f = Files.createFile(locksDir.resolve("foo")).toFile();
+    ScheduledFuture<?> touchTask = service.touchForever(f);
+    touchTask.cancel(false);
+    verifyLastUpdatedDoesNotIncrease(f);
+  }
+
+  @Test
+  public void touchTaskStopsWhenFileDisappears() throws Exception {
+    File f = Files.createFile(locksDir.resolve("foo")).toFile();
+    ScheduledFuture<?> touchTask = service.touchForever(f);
+    Thread.sleep(touchFileInterval.toMillis());
+
+    assertThat(touchTask.isDone()).isFalse();
+
+    assertThat(f.delete()).isTrue();
+    Thread.sleep(touchFileInterval.toMillis());
+
+    assertThat(touchTask.isDone()).isTrue();
+    try {
+      touchTask.get();
+      Assert.fail("Expected an exception from touchTask.get()");
+    } catch (ExecutionException e) {
+      assertThat(e.getCause()).isInstanceOf(RuntimeException.class);
+      RuntimeException cause = (RuntimeException) e.getCause();
+      assertThat(cause.getMessage()).contains("stopping");
+    }
+  }
+
+  private void verifyLastUpdatedIncreases(File f) throws InterruptedException {
+    long start = f.lastModified();
+    long previous = start;
+    long last = start;
+    for (int i = 0; i < 3; i++) {
+      Thread.sleep(1000);
+      long current = f.lastModified();
+      assertThat(current).isAtLeast(previous);
+      last = current;
+    }
+    assertThat(last).isGreaterThan(start);
+  }
+
+  private void verifyLastUpdatedDoesNotIncrease(File f) throws InterruptedException {
+    long start = f.lastModified();
+    for (int i = 0; i < 3; i++) {
+      Thread.sleep(1000);
+      long current = f.lastModified();
+      assertThat(current).isEqualTo(start);
+    }
+  }
+}