Merge "Implement LockManager extension using files to represent locks"
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 4611790..1a76c66 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.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.Inject;
@@ -43,6 +44,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/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/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);
+    }
+  }
+}