Merge branch 'stable-3.11' into stable-3.12

* stable-3.11:
  Add missing binding of ProjectDeletedListener
  Fix the Docker-based setup for the HA test environment
  Verify high-availability formatting using GJF 1.7
  Use Java 21 for the Gerrit image
  Fix incorrect conflict resolution in Iaf0960a595
  Use GerritForge fork of the nfs-server

Change-Id: I0285d2c7efcb9dbd52dab88ab14649f813c0f40d
diff --git a/README.md b/README.md
index c8ac4b4..ef81595 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@
 
 Requirements for the Gerrit masters are:
 
-- Gerrit v2.14.20 or later
 - Externally mounted filesystem shared among the cluster
 - Load-balancer (HAProxy or similar)
 
@@ -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
@@ -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/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/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/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/docker/gerrit/Dockerfile b/src/test/docker/gerrit/Dockerfile
index f082d2f..f5f7bf5 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_VERSION 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/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);
+    }
+  }
+}