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);
+ }
+ }
+}