Merge branch 'stable-3.8' into stable-3.9

* stable-3.8:
  Add missing binding of ProjectDeletedListener
  Fix the Docker-based setup for the HA test environment
  Verify high-availability formatting using GJF 1.7

Also revert the GJF 1.7 change and leave the default formatting version
from Gerrit and fix the formatting issues.

Change-Id: I1858c0f059263fee9bb80206d846fb2235b754ac
diff --git a/BUILD b/BUILD
index 01ed84f..9bb0445 100644
--- a/BUILD
+++ b/BUILD
@@ -22,6 +22,7 @@
     deps = [
       "@jgroups//jar",
       "@jgroups-kubernetes//jar",
+      "@failsafe//jar",
       ":global-refdb-neverlink",
     ],
 )
@@ -55,6 +56,7 @@
         "@global-refdb//jar",
         "@wiremock//jar",
         "@jgroups//jar",
-        "@commons-net//jar"
+        "@commons-net//jar",
+        "@failsafe//jar",
     ],
 )
diff --git a/Jenkinsfile b/Jenkinsfile
index 503cf93..98183a5 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,4 +1,3 @@
 pluginPipeline(formatCheckId: 'gerritforge:plugins-high-availability-code-style',
                buildCheckId: 'gerritforge:plugins-high-availability-build-test',
-               gjfVersion: '1.7',
                extraModules: ['global-refdb'])
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
index 09f34de..02c1df5 100644
--- a/external_plugin_deps.bzl
+++ b/external_plugin_deps.bzl
@@ -24,3 +24,9 @@
         artifact = "com.gerritforge:global-refdb:3.7.4",
         sha1 = "a5f3fcdbc04b7e98c52ecd50d2a56424e60b0575",
     )
+
+    maven_jar(
+        name = "failsafe",
+        artifact = "dev.failsafe:failsafe:3.3.2",
+        sha1 = "738a986f1f0e4b6c6a49d351dddc772d1378c5a8",
+    )
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
index fd5f2cb..55f7375 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
@@ -14,7 +14,6 @@
 
 package com.ericsson.gerrit.plugins.highavailability;
 
-import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbConfiguration;
@@ -33,13 +32,13 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -50,8 +49,7 @@
 public class Configuration {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
-  public static final int DEFAULT_NUM_STRIPED_LOCKS = 10;
-  public static final int DEFAULT_TIMEOUT_MS = 5000;
+  public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(5);
   public static final String PLUGIN_NAME = "high-availability";
   public static final String PLUGIN_CONFIG_FILE = PLUGIN_NAME + ".config";
 
@@ -64,10 +62,7 @@
   // common parameters to cache and index sections
   static final String THREAD_POOL_SIZE_KEY = "threadPoolSize";
   static final String BATCH_THREAD_POOL_SIZE_KEY = "batchThreadPoolSize";
-  static final int DEFAULT_INDEX_MAX_TRIES = 2;
-  static final int DEFAULT_INDEX_RETRY_INTERVAL = 30000;
   static final int DEFAULT_THREAD_POOL_SIZE = 4;
-  static final String NUM_STRIPED_LOCKS = "numStripedLocks";
 
   private final Main main;
   private final AutoReindex autoReindex;
@@ -89,6 +84,11 @@
     STATIC
   }
 
+  public enum Transport {
+    HTTP,
+    JGROUPS
+  }
+
   @Inject
   Configuration(SitePaths sitePaths) {
     this(getConfigFile(sitePaths, PLUGIN_CONFIG_FILE), sitePaths);
@@ -199,14 +199,29 @@
     }
   }
 
+  private static int getMaxTries(Config cfg, String section, String name, int defaultValue) {
+    int v = getInt(cfg, section, name, defaultValue);
+    return 1 <= v ? v : defaultValue;
+  }
+
+  private static Duration getDuration(
+      Config cfg, String section, String setting, Duration defaultValue) {
+    return Duration.ofMillis(
+        ConfigUtil.getTimeUnit(cfg, section, null, setting, defaultValue.toMillis(), MILLISECONDS));
+  }
+
   public static class Main {
     static final String MAIN_SECTION = "main";
     static final String SHARED_DIRECTORY_KEY = "sharedDirectory";
     static final String DEFAULT_SHARED_DIRECTORY = "shared";
+    static final String TRANSPORT_KEY = "transport";
+    static final Transport DEFAULT_TRANSPORT = Transport.HTTP;
 
+    private final Transport transport;
     private final Path sharedDirectory;
 
     private Main(SitePaths site, Config cfg) {
+      transport = cfg.getEnum(MAIN_SECTION, null, TRANSPORT_KEY, DEFAULT_TRANSPORT);
       String shared = Strings.emptyToNull(cfg.getString(MAIN_SECTION, null, SHARED_DIRECTORY_KEY));
       if (shared == null) {
         shared = DEFAULT_SHARED_DIRECTORY;
@@ -219,6 +234,10 @@
       }
     }
 
+    public Transport transport() {
+      return transport;
+    }
+
     public Path sharedDirectory() {
       return sharedDirectory;
     }
@@ -231,38 +250,29 @@
     static final String DELAY = "delay";
     static final String POLL_INTERVAL = "pollInterval";
     static final boolean DEFAULT_AUTO_REINDEX = false;
-    static final long DEFAULT_DELAY = 10L;
-    static final long DEFAULT_POLL_INTERVAL = 0L;
+    static final Duration DEFAULT_DELAY = Duration.ofSeconds(10);
+    static final Duration DEFAULT_POLL_INTERVAL = Duration.ZERO;
 
     private final boolean enabled;
-    private final long delaySec;
-    private final long pollSec;
+    private final Duration delay;
+    private final Duration pollInterval;
 
     public AutoReindex(Config cfg) {
       enabled = cfg.getBoolean(AUTO_REINDEX_SECTION, ENABLED, DEFAULT_AUTO_REINDEX);
-      delaySec =
-          ConfigUtil.getTimeUnit(
-              cfg, AUTO_REINDEX_SECTION, null, DELAY, DEFAULT_DELAY, TimeUnit.SECONDS);
-      pollSec =
-          ConfigUtil.getTimeUnit(
-              cfg,
-              AUTO_REINDEX_SECTION,
-              null,
-              POLL_INTERVAL,
-              DEFAULT_POLL_INTERVAL,
-              TimeUnit.SECONDS);
+      delay = getDuration(cfg, AUTO_REINDEX_SECTION, DELAY, DEFAULT_DELAY);
+      pollInterval = getDuration(cfg, AUTO_REINDEX_SECTION, POLL_INTERVAL, DEFAULT_POLL_INTERVAL);
     }
 
     public boolean enabled() {
       return enabled;
     }
 
-    public long delaySec() {
-      return delaySec;
+    public Duration delay() {
+      return delay;
     }
 
-    public long pollSec() {
-      return pollSec;
+    public Duration pollInterval() {
+      return pollInterval;
     }
   }
 
@@ -325,8 +335,14 @@
   }
 
   public static class JGroups {
+    static final int DEFAULT_MAX_TRIES = 720;
+    static final Duration DEFAULT_RETRY_INTERVAL = Duration.ofSeconds(10);
+
     static final String SKIP_INTERFACE_KEY = "skipInterface";
     static final String CLUSTER_NAME_KEY = "clusterName";
+    static final String MAX_TRIES_KEY = "maxTries";
+    static final String RETRY_INTERVAL_KEY = "retryInterval";
+    static final String TIMEOUT_KEY = "timeout";
     static final String KUBERNETES_KEY = "kubernetes";
     static final String PROTOCOL_STACK_KEY = "protocolStack";
     static final ImmutableList<String> DEFAULT_SKIP_INTERFACE_LIST =
@@ -335,6 +351,10 @@
 
     private final ImmutableList<String> skipInterface;
     private final String clusterName;
+    private final Duration timeout;
+    private final int maxTries;
+    private final Duration retryInterval;
+    private final int threadPoolSize;
     private final boolean useKubernetes;
     private final Optional<Path> protocolStack;
 
@@ -344,6 +364,10 @@
       log.atFine().log("Skip interface(s): %s", skipInterface);
       clusterName = getString(cfg, JGROUPS_SECTION, null, CLUSTER_NAME_KEY, DEFAULT_CLUSTER_NAME);
       log.atFine().log("Cluster name: %s", clusterName);
+      timeout = getDuration(cfg, JGROUPS_SECTION, TIMEOUT_KEY, DEFAULT_TIMEOUT);
+      maxTries = getMaxTries(cfg, JGROUPS_SECTION, MAX_TRIES_KEY, DEFAULT_MAX_TRIES);
+      retryInterval = getDuration(cfg, JGROUPS_SECTION, RETRY_INTERVAL_KEY, DEFAULT_RETRY_INTERVAL);
+      threadPoolSize = getInt(cfg, JGROUPS_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
       useKubernetes = cfg.getBoolean(JGROUPS_SECTION, KUBERNETES_KEY, false);
       protocolStack = getProtocolStack(cfg, site);
       log.atFine().log(
@@ -374,6 +398,22 @@
       return clusterName;
     }
 
+    public Duration timeout() {
+      return timeout;
+    }
+
+    public int maxTries() {
+      return maxTries;
+    }
+
+    public Duration retryInterval() {
+      return retryInterval;
+    }
+
+    public int threadPoolSize() {
+      return threadPoolSize;
+    }
+
     public boolean useKubernetes() {
       return useKubernetes;
     }
@@ -403,7 +443,8 @@
 
   public static class Http {
     public static final int DEFAULT_MAX_TRIES = 360;
-    public static final int DEFAULT_RETRY_INTERVAL = 10000;
+    public static final Duration DEFAULT_RETRY_INTERVAL = Duration.ofSeconds(10);
+    public static final int DEFAULT_THREAD_POOL_SIZE = 4;
 
     static final String HTTP_SECTION = "http";
     static final String USER_KEY = "user";
@@ -412,21 +453,24 @@
     static final String SOCKET_TIMEOUT_KEY = "socketTimeout";
     static final String MAX_TRIES_KEY = "maxTries";
     static final String RETRY_INTERVAL_KEY = "retryInterval";
+    static final String THREAD_POOL_SIZE_KEY = "threadPoolSize";
 
     private final String user;
     private final String password;
-    private final int connectionTimeout;
-    private final int socketTimeout;
+    private final Duration connectionTimeout;
+    private final Duration socketTimeout;
     private final int maxTries;
-    private final int retryInterval;
+    private final Duration retryInterval;
+    private final int threadPoolSize;
 
     private Http(Config cfg) {
       user = Strings.nullToEmpty(cfg.getString(HTTP_SECTION, null, USER_KEY));
       password = Strings.nullToEmpty(cfg.getString(HTTP_SECTION, null, PASSWORD_KEY));
-      connectionTimeout = getInt(cfg, HTTP_SECTION, CONNECTION_TIMEOUT_KEY, DEFAULT_TIMEOUT_MS);
-      socketTimeout = getInt(cfg, HTTP_SECTION, SOCKET_TIMEOUT_KEY, DEFAULT_TIMEOUT_MS);
-      maxTries = getInt(cfg, HTTP_SECTION, MAX_TRIES_KEY, DEFAULT_MAX_TRIES);
-      retryInterval = getInt(cfg, HTTP_SECTION, RETRY_INTERVAL_KEY, DEFAULT_RETRY_INTERVAL);
+      connectionTimeout = getDuration(cfg, HTTP_SECTION, CONNECTION_TIMEOUT_KEY, DEFAULT_TIMEOUT);
+      socketTimeout = getDuration(cfg, HTTP_SECTION, SOCKET_TIMEOUT_KEY, DEFAULT_TIMEOUT);
+      maxTries = getMaxTries(cfg, HTTP_SECTION, MAX_TRIES_KEY, DEFAULT_MAX_TRIES);
+      retryInterval = getDuration(cfg, HTTP_SECTION, RETRY_INTERVAL_KEY, DEFAULT_RETRY_INTERVAL);
+      threadPoolSize = getInt(cfg, HTTP_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
     }
 
     public String user() {
@@ -437,11 +481,11 @@
       return password;
     }
 
-    public int connectionTimeout() {
+    public Duration connectionTimeout() {
       return connectionTimeout;
     }
 
-    public int socketTimeout() {
+    public Duration socketTimeout() {
       return socketTimeout;
     }
 
@@ -449,9 +493,13 @@
       return maxTries;
     }
 
-    public int retryInterval() {
+    public Duration retryInterval() {
       return retryInterval;
     }
+
+    public int threadPoolSize() {
+      return threadPoolSize;
+    }
   }
 
   /** Common parameters to cache, event, index and websession */
@@ -485,19 +533,13 @@
     static final String CACHE_SECTION = "cache";
     static final String PATTERN_KEY = "pattern";
 
-    private final int threadPoolSize;
     private final List<String> patterns;
 
     private Cache(Config cfg) {
       super(cfg, CACHE_SECTION);
-      threadPoolSize = getInt(cfg, CACHE_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
       patterns = Arrays.asList(cfg.getStringList(CACHE_SECTION, null, PATTERN_KEY));
     }
 
-    public int threadPoolSize() {
-      return threadPoolSize;
-    }
-
     public List<String> patterns() {
       return Collections.unmodifiableList(patterns);
     }
@@ -521,27 +563,27 @@
   }
 
   public static class Index extends Forwarding {
+    static final int DEFAULT_MAX_TRIES = 2;
+    static final Duration DEFAULT_RETRY_INTERVAL = Duration.ofSeconds(30);
+
     static final String INDEX_SECTION = "index";
     static final String MAX_TRIES_KEY = "maxTries";
-    static final String WAIT_TIMEOUT_KEY = "waitTimeout";
     static final String RETRY_INTERVAL_KEY = "retryInterval";
     static final String SYNCHRONIZE_FORCED_KEY = "synchronizeForced";
     static final boolean DEFAULT_SYNCHRONIZE_FORCED = true;
 
     private final int threadPoolSize;
     private final int batchThreadPoolSize;
-    private final int retryInterval;
+    private final Duration retryInterval;
     private final int maxTries;
-    private final int numStripedLocks;
     private final boolean synchronizeForced;
 
     private Index(Config cfg) {
       super(cfg, INDEX_SECTION);
       threadPoolSize = getInt(cfg, INDEX_SECTION, THREAD_POOL_SIZE_KEY, DEFAULT_THREAD_POOL_SIZE);
       batchThreadPoolSize = getInt(cfg, INDEX_SECTION, BATCH_THREAD_POOL_SIZE_KEY, threadPoolSize);
-      numStripedLocks = getInt(cfg, INDEX_SECTION, NUM_STRIPED_LOCKS, DEFAULT_NUM_STRIPED_LOCKS);
-      retryInterval = getInt(cfg, INDEX_SECTION, RETRY_INTERVAL_KEY, DEFAULT_INDEX_RETRY_INTERVAL);
-      maxTries = getInt(cfg, INDEX_SECTION, MAX_TRIES_KEY, DEFAULT_INDEX_MAX_TRIES);
+      retryInterval = getDuration(cfg, INDEX_SECTION, RETRY_INTERVAL_KEY, DEFAULT_RETRY_INTERVAL);
+      maxTries = getMaxTries(cfg, INDEX_SECTION, MAX_TRIES_KEY, DEFAULT_MAX_TRIES);
       synchronizeForced =
           cfg.getBoolean(INDEX_SECTION, SYNCHRONIZE_FORCED_KEY, DEFAULT_SYNCHRONIZE_FORCED);
     }
@@ -554,11 +596,7 @@
       return batchThreadPoolSize;
     }
 
-    public int numStripedLocks() {
-      return numStripedLocks;
-    }
-
-    public int retryInterval() {
+    public Duration retryInterval() {
       return retryInterval;
     }
 
@@ -574,21 +612,18 @@
   public static class Websession extends Forwarding {
     static final String WEBSESSION_SECTION = "websession";
     static final String CLEANUP_INTERVAL_KEY = "cleanupInterval";
-    static final String DEFAULT_CLEANUP_INTERVAL = "24 hours";
-    static final long DEFAULT_CLEANUP_INTERVAL_MS = HOURS.toMillis(24);
+    static final String DEFAULT_CLEANUP_INTERVAL_AS_STRING = "24 hours";
+    static final Duration DEFAULT_CLEANUP_INTERVAL = Duration.ofHours(24);
 
-    private final long cleanupInterval;
+    private final Duration cleanupInterval;
 
     private Websession(Config cfg) {
       super(cfg, WEBSESSION_SECTION);
       cleanupInterval =
-          ConfigUtil.getTimeUnit(
-              Strings.nullToEmpty(cfg.getString(WEBSESSION_SECTION, null, CLEANUP_INTERVAL_KEY)),
-              DEFAULT_CLEANUP_INTERVAL_MS,
-              MILLISECONDS);
+          getDuration(cfg, WEBSESSION_SECTION, CLEANUP_INTERVAL_KEY, DEFAULT_CLEANUP_INTERVAL);
     }
 
-    public long cleanupInterval() {
+    public Duration cleanupInterval() {
       return cleanupInterval;
     }
   }
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 26bdd5d..170a79a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
@@ -18,6 +18,7 @@
 import com.ericsson.gerrit.plugins.highavailability.cache.CacheModule;
 import com.ericsson.gerrit.plugins.highavailability.event.EventModule;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwarderModule;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups.JGroupsForwarderModule;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarderModule;
 import com.ericsson.gerrit.plugins.highavailability.index.IndexModule;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfoModule;
@@ -44,7 +45,18 @@
   protected void configure() {
     install(new EnvModule());
     install(new ForwarderModule());
-    install(new RestForwarderModule());
+
+    switch (config.main().transport()) {
+      case HTTP:
+        install(new RestForwarderModule());
+        install(new PeerInfoModule(config.peerInfo().strategy()));
+        break;
+      case JGROUPS:
+        install(new JGroupsForwarderModule());
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported transport: " + config.main().transport());
+    }
 
     if (config.cache().synchronize()) {
       install(new CacheModule());
@@ -58,7 +70,6 @@
     if (config.autoReindex().enabled()) {
       install(new AutoReindexModule());
     }
-    install(new PeerInfoModule(config.peerInfo().strategy()));
 
     if (config.sharedRefDb().getSharedRefDb().isEnabled()) {
       listener().to(PluginStartup.class);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
index 8a267d6..a089b23 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Setup.java
@@ -24,7 +24,7 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Cache.CACHE_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Cache.PATTERN_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_THREAD_POOL_SIZE;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT_MS;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Event.EVENT_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Forwarding.DEFAULT_SYNCHRONIZE;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Forwarding.SYNCHRONIZE_KEY;
@@ -55,7 +55,7 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStatic.URL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.THREAD_POOL_SIZE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.CLEANUP_INTERVAL_KEY;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.DEFAULT_CLEANUP_INTERVAL;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.DEFAULT_CLEANUP_INTERVAL_AS_STRING;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.WEBSESSION_SECTION;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStrategy;
@@ -135,7 +135,8 @@
     config.setBoolean(AUTO_REINDEX_SECTION, null, ENABLED, autoReindex);
 
     String delay =
-        promptAndSetString("Delay", AUTO_REINDEX_SECTION, DELAY, numberToString(DEFAULT_DELAY));
+        promptAndSetString(
+            "Delay", AUTO_REINDEX_SECTION, DELAY, numberToString(DEFAULT_DELAY.toMillis()));
     config.setLong(AUTO_REINDEX_SECTION, null, DELAY, Long.valueOf(delay));
 
     String pollInterval =
@@ -143,7 +144,7 @@
             "Poll interval",
             AUTO_REINDEX_SECTION,
             POLL_INTERVAL,
-            numberToString(DEFAULT_POLL_INTERVAL));
+            numberToString(DEFAULT_POLL_INTERVAL.toMillis()));
     config.setLong(AUTO_REINDEX_SECTION, null, POLL_INTERVAL, Long.valueOf(pollInterval));
   }
 
@@ -203,17 +204,17 @@
         "Retry interval [ms]",
         HTTP_SECTION,
         RETRY_INTERVAL_KEY,
-        numberToString(DEFAULT_RETRY_INTERVAL));
+        numberToString(DEFAULT_RETRY_INTERVAL.toMillis()));
     promptAndSetString(
         "Connection timeout [ms]",
         HTTP_SECTION,
         CONNECTION_TIMEOUT_KEY,
-        numberToString(DEFAULT_TIMEOUT_MS));
+        numberToString(DEFAULT_TIMEOUT.toMillis()));
     promptAndSetString(
         "Socket timeout [ms]",
         HTTP_SECTION,
         SOCKET_TIMEOUT_KEY,
-        numberToString(DEFAULT_TIMEOUT_MS));
+        numberToString(DEFAULT_TIMEOUT.toMillis()));
   }
 
   private void configureCacheSection() {
@@ -247,7 +248,10 @@
     ui.header("Websession section");
     promptAndSetSynchronize("Websession", WEBSESSION_SECTION);
     promptAndSetString(
-        "Cleanup interval", WEBSESSION_SECTION, CLEANUP_INTERVAL_KEY, DEFAULT_CLEANUP_INTERVAL);
+        "Cleanup interval",
+        WEBSESSION_SECTION,
+        CLEANUP_INTERVAL_KEY,
+        DEFAULT_CLEANUP_INTERVAL_AS_STRING);
   }
 
   private void configureHealthCheckSection() {
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 ef7ae86..e948ba4 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
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Future;
@@ -55,27 +56,24 @@
 
   @Override
   public void start() {
-    if (cfg.pollSec() > 0) {
+    if (cfg.pollInterval().compareTo(Duration.ZERO) > 0) {
       log.atInfo().log(
-          "Scheduling auto-reindex after %ds and every %ds", cfg.delaySec(), cfg.pollSec());
-      futureTasks.add(
-          executor.scheduleAtFixedRate(
-              changeReindex, cfg.delaySec(), cfg.pollSec(), TimeUnit.SECONDS));
-      futureTasks.add(
-          executor.scheduleAtFixedRate(
-              accountReindex, cfg.delaySec(), cfg.pollSec(), TimeUnit.SECONDS));
-      futureTasks.add(
-          executor.scheduleAtFixedRate(
-              groupReindex, cfg.delaySec(), cfg.pollSec(), TimeUnit.SECONDS));
-      futureTasks.add(
-          executor.scheduleAtFixedRate(
-              projectReindex, cfg.delaySec(), cfg.pollSec(), TimeUnit.SECONDS));
+          "Scheduling auto-reindex after %s and every %s", cfg.delay(), cfg.pollInterval());
+      for (Runnable reindexTask :
+          List.of(changeReindex, accountReindex, groupReindex, projectReindex)) {
+        futureTasks.add(
+            executor.scheduleAtFixedRate(
+                reindexTask,
+                cfg.delay().toSeconds(),
+                cfg.pollInterval().toSeconds(),
+                TimeUnit.SECONDS));
+      }
     } else {
-      log.atInfo().log("Scheduling auto-reindex after %ds", cfg.delaySec());
-      futureTasks.add(executor.schedule(changeReindex, cfg.delaySec(), TimeUnit.SECONDS));
-      futureTasks.add(executor.schedule(accountReindex, cfg.delaySec(), TimeUnit.SECONDS));
-      futureTasks.add(executor.schedule(groupReindex, cfg.delaySec(), TimeUnit.SECONDS));
-      futureTasks.add(executor.schedule(projectReindex, cfg.delaySec(), TimeUnit.SECONDS));
+      log.atInfo().log("Scheduling auto-reindex after %s", cfg.delay());
+      for (Runnable reindexTask :
+          List.of(changeReindex, accountReindex, groupReindex, projectReindex)) {
+        futureTasks.add(executor.schedule(reindexTask, cfg.delay().toSeconds(), TimeUnit.SECONDS));
+      }
     }
   }
 
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
index 6597ac5..53bc3a1 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandler.java
@@ -17,54 +17,23 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.google.common.cache.RemovalNotification;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.inject.Inject;
-import java.util.concurrent.Executor;
 
 class CacheEvictionHandler<K, V> implements CacheRemovalListener<K, V> {
-  private final Executor executor;
   private final Forwarder forwarder;
-  private final String pluginName;
   private final CachePatternMatcher matcher;
 
   @Inject
-  CacheEvictionHandler(
-      Forwarder forwarder,
-      @CacheExecutor Executor executor,
-      @PluginName String pluginName,
-      CachePatternMatcher matcher) {
+  CacheEvictionHandler(Forwarder forwarder, CachePatternMatcher matcher) {
     this.forwarder = forwarder;
-    this.executor = executor;
-    this.pluginName = pluginName;
     this.matcher = matcher;
   }
 
   @Override
   public void onRemoval(String plugin, String cache, RemovalNotification<K, V> notification) {
     if (!Context.isForwardedEvent() && !notification.wasEvicted() && matcher.matches(cache)) {
-      executor.execute(new CacheEvictionTask(cache, notification.getKey()));
-    }
-  }
-
-  class CacheEvictionTask implements Runnable {
-    private final String cacheName;
-    private final Object key;
-
-    CacheEvictionTask(String cacheName, Object key) {
-      this.cacheName = cacheName;
-      this.key = key;
-    }
-
-    @Override
-    public void run() {
-      forwarder.evict(cacheName, key);
-    }
-
-    @Override
-    public String toString() {
-      return String.format(
-          "[%s] Evict key '%s' from cache '%s' in target instance", pluginName, key, cacheName);
+      forwarder.evict(cache, notification.getKey());
     }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProvider.java
deleted file mode 100644
index 0872dff..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProvider.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2015 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.cache;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class CacheExecutorProvider extends ExecutorProvider {
-
-  @Inject
-  CacheExecutorProvider(WorkQueue workQueue, Configuration config) {
-    super(workQueue, config.cache().threadPoolSize(), "Forward-Cache-Eviction-Event");
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheModule.java
index 17e2613..aa613e1 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheModule.java
@@ -17,16 +17,13 @@
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
-import java.util.concurrent.Executor;
+import com.google.inject.AbstractModule;
 
-public class CacheModule extends LifecycleModule {
+public class CacheModule extends AbstractModule {
 
   @Override
   protected void configure() {
-    bind(Executor.class).annotatedWith(CacheExecutor.class).toProvider(CacheExecutorProvider.class);
-    listener().to(CacheExecutorProvider.class);
     DynamicSet.bind(binder(), CacheRemovalListener.class).to(CacheEvictionHandler.class);
     DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(ProjectListUpdateHandler.class);
     DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ProjectListUpdateHandler.class);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandler.java
index 7205137..9ce9536 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandler.java
@@ -16,27 +16,20 @@
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.events.ProjectEvent;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.concurrent.Executor;
 
 @Singleton
 public class ProjectListUpdateHandler implements NewProjectCreatedListener, ProjectDeletedListener {
 
   private final Forwarder forwarder;
-  private final Executor executor;
-  private final String pluginName;
 
   @Inject
-  public ProjectListUpdateHandler(
-      Forwarder forwarder, @CacheExecutor Executor executor, @PluginName String pluginName) {
+  public ProjectListUpdateHandler(Forwarder forwarder) {
     this.forwarder = forwarder;
-    this.executor = executor;
-    this.pluginName = pluginName;
   }
 
   @Override
@@ -53,33 +46,11 @@
 
   private void process(ProjectEvent event, boolean delete) {
     if (!Context.isForwardedEvent()) {
-      executor.execute(new ProjectListUpdateTask(event.getProjectName(), delete));
-    }
-  }
-
-  class ProjectListUpdateTask implements Runnable {
-    private final String projectName;
-    private final boolean delete;
-
-    ProjectListUpdateTask(String projectName, boolean delete) {
-      this.projectName = projectName;
-      this.delete = delete;
-    }
-
-    @Override
-    public void run() {
       if (delete) {
-        forwarder.removeFromProjectList(projectName);
+        forwarder.removeFromProjectList(event.getProjectName());
       } else {
-        forwarder.addToProjectList(projectName);
+        forwarder.addToProjectList(event.getProjectName());
       }
     }
-
-    @Override
-    public String toString() {
-      return String.format(
-          "[%s] Update project list in target instance: %s '%s'",
-          pluginName, delete ? "remove" : "add", projectName);
-    }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutor.java
deleted file mode 100644
index 726484c..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutor.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 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.event;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface EventExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProvider.java
deleted file mode 100644
index b39eca2..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProvider.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2015 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.event;
-
-import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class EventExecutorProvider extends ExecutorProvider {
-
-  @Inject
-  EventExecutorProvider(WorkQueue workQueue) {
-    super(workQueue, 1, "Forward-Stream-Event");
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandler.java
index 025115c..5ce12df 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandler.java
@@ -16,48 +16,23 @@
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.inject.Inject;
-import java.util.concurrent.Executor;
 
 class EventHandler implements EventListener {
-  private final Executor executor;
   private final Forwarder forwarder;
-  private final String pluginName;
 
   @Inject
-  EventHandler(
-      Forwarder forwarder, @EventExecutor Executor executor, @PluginName String pluginName) {
+  EventHandler(Forwarder forwarder) {
     this.forwarder = forwarder;
-    this.executor = executor;
-    this.pluginName = pluginName;
   }
 
   @Override
   public void onEvent(Event event) {
     if (!Context.isForwardedEvent() && event instanceof ProjectEvent) {
-      executor.execute(new EventTask(event));
-    }
-  }
-
-  class EventTask implements Runnable {
-    private final Event event;
-
-    EventTask(Event event) {
-      this.event = event;
-    }
-
-    @Override
-    public void run() {
       forwarder.send(event);
     }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Send event '%s' to target instance", pluginName, event.type);
-    }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventModule.java
index 28a9e8e..fe67f41 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/event/EventModule.java
@@ -17,14 +17,11 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.events.EventListener;
-import java.util.concurrent.Executor;
 
 public class EventModule extends LifecycleModule {
 
   @Override
   protected void configure() {
-    bind(Executor.class).annotatedWith(EventExecutor.class).toProvider(EventExecutorProvider.class);
-    listener().to(EventExecutorProvider.class);
     DynamicSet.bind(binder(), EventListener.class).to(EventHandler.class);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/CacheEntry.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/CacheEntry.java
index ed9898f..6dca538 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/CacheEntry.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/CacheEntry.java
@@ -15,6 +15,7 @@
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
 import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
+import java.util.Objects;
 
 /** Represents a cache entry to evict */
 public class CacheEntry {
@@ -48,6 +49,22 @@
     return key;
   }
 
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof CacheEntry) {
+      CacheEntry e = (CacheEntry) o;
+      return Objects.equals(pluginName, e.pluginName)
+          && Objects.equals(cacheName, e.cacheName)
+          && Objects.equals(key, e.key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, cacheName, key);
+  }
+
   /**
    * Build a CacheEntry from the specified cache and key
    *
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandler.java
index b1a595c..b2f8141 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandler.java
@@ -19,6 +19,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * Index an account using {@link AccountIndexer}. This class is meant to be used on the receiving
@@ -36,13 +37,14 @@
   }
 
   @Override
-  protected void doIndex(Account.Id id, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doIndex(Account.Id id, Optional<IndexEvent> indexEvent) {
     indexer.index(id);
     log.atFine().log("Account %s successfully indexed", id);
+    return CompletableFuture.completedFuture(true);
   }
 
   @Override
-  protected void doDelete(Account.Id id, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doDelete(Account.Id id, Optional<IndexEvent> indexEvent) {
     throw new UnsupportedOperationException("Delete from account index not supported");
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
index dee8876..bf48f76 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
@@ -14,14 +14,13 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl.Factory;
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedBatchIndexExecutor;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.concurrent.ScheduledExecutorService;
+import dev.failsafe.FailsafeExecutor;
 
 @Singleton
 public class ForwardedIndexBatchChangeHandler extends ForwardedIndexChangeHandler {
@@ -29,10 +28,9 @@
   @Inject
   ForwardedIndexBatchChangeHandler(
       ChangeIndexer indexer,
-      Configuration config,
-      @ForwardedBatchIndexExecutor ScheduledExecutorService indexExecutor,
+      @ForwardedBatchIndexExecutor FailsafeExecutor<Boolean> indexExecutor,
       OneOffRequestContext oneOffCtx,
       Factory changeCheckerFactory) {
-    super(indexer, config, indexExecutor, oneOffCtx, changeCheckerFactory);
+    super(indexer, indexExecutor, oneOffCtx, changeCheckerFactory);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
index 9663205..ac102ff 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
@@ -14,8 +14,6 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.ericsson.gerrit.plugins.highavailability.Configuration.Index;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeChecker;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl;
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutor;
@@ -30,11 +28,11 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import dev.failsafe.FailsafeExecutor;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * Index a change using {@link ChangeIndexer}. This class is meant to be used on the receiving side
@@ -45,36 +43,35 @@
 @Singleton
 public class ForwardedIndexChangeHandler extends ForwardedIndexingHandler<String> {
   private final ChangeIndexer indexer;
-  private final ScheduledExecutorService indexExecutor;
+  private final FailsafeExecutor<Boolean> indexExecutor;
   private final OneOffRequestContext oneOffCtx;
-  private final int retryInterval;
-  private final int maxTries;
   private final ChangeCheckerImpl.Factory changeCheckerFactory;
 
   @Inject
   ForwardedIndexChangeHandler(
       ChangeIndexer indexer,
-      Configuration config,
-      @ForwardedIndexExecutor ScheduledExecutorService indexExecutor,
+      @ForwardedIndexExecutor FailsafeExecutor<Boolean> indexExecutor,
       OneOffRequestContext oneOffCtx,
       ChangeCheckerImpl.Factory changeCheckerFactory) {
     this.indexer = indexer;
     this.indexExecutor = indexExecutor;
     this.oneOffCtx = oneOffCtx;
     this.changeCheckerFactory = changeCheckerFactory;
-
-    Index indexConfig = config.index();
-    this.retryInterval = indexConfig != null ? indexConfig.retryInterval() : 0;
-    this.maxTries = indexConfig != null ? indexConfig.maxTries() : 0;
   }
 
   @Override
-  protected void doIndex(String id, Optional<IndexEvent> indexEvent) throws IOException {
-    doIndex(id, indexEvent, 0);
+  protected CompletableFuture<Boolean> doIndex(String id, Optional<IndexEvent> indexEvent)
+      throws IOException {
+    return indexExecutor.getAsync(
+        () -> {
+          try (ManualRequestContext ctx = oneOffCtx.open()) {
+            Context.setForwardedEvent(true);
+            return indexOnce(id, indexEvent);
+          }
+        });
   }
 
-  private void doIndex(String id, Optional<IndexEvent> indexEvent, int retryCount)
-      throws IOException {
+  private boolean indexOnce(String id, Optional<IndexEvent> indexEvent) throws Exception {
     try {
       ChangeChecker checker = changeCheckerFactory.create(id);
       Optional<ChangeNotes> changeNotes;
@@ -89,33 +86,25 @@
         reindex(notes);
 
         if (checker.isChangeUpToDate(indexEvent)) {
-          if (retryCount > 0) {
-            log.atWarning().log(
-                "Change %s has been eventually indexed after %d attempt(s)", id, retryCount);
-          } else {
-            log.atFine().log("Change %s successfully indexed", id);
-          }
-        } else {
-          log.atWarning().log(
-              "Change %s seems too old compared to the event timestamp (event-Ts=%s >> change-Ts=%s)",
-              id, indexEvent, checker);
-          rescheduleIndex(id, indexEvent, retryCount + 1);
+          log.atFine().log("Change %s successfully indexed", id);
+          return true;
         }
-      } else {
+
         log.atWarning().log(
-            "Change %s not present yet in local Git repository (event=%s) after %d attempt(s)",
-            id, indexEvent, retryCount);
-        if (!rescheduleIndex(id, indexEvent, retryCount + 1)) {
-          log.atSevere().log(
-              "Change %s could not be found in the local Git repository (event=%s)",
-              id, indexEvent);
-        }
+            "Change %s seems too old compared to the event timestamp (event-Ts=%s >> change-Ts=%s)",
+            id, indexEvent, checker);
+        return false;
       }
+
+      log.atWarning().log(
+          "Change %s not present yet in local Git repository (event=%s)", id, indexEvent);
+      return false;
+
     } catch (Exception e) {
       if (isCausedByNoSuchChangeException(e)) {
         indexer.delete(parseChangeId(id));
         log.atWarning().withCause(e).log("Error trying to index Change %s. Deleted from index", id);
-        return;
+        return true;
       }
 
       throw e;
@@ -127,35 +116,12 @@
     indexer.index(notes);
   }
 
-  private boolean rescheduleIndex(String id, Optional<IndexEvent> indexEvent, int retryCount) {
-    if (retryCount > maxTries) {
-      log.atSevere().log(
-          "Change %s could not be indexed after %d retries. Change index could be stale.",
-          id, retryCount);
-      return false;
-    }
-
-    log.atWarning().log(
-        "Retrying for the #%d time to index Change %s after %d msecs",
-        retryCount, id, retryInterval);
-    indexExecutor.schedule(
-        () -> {
-          try (ManualRequestContext ctx = oneOffCtx.open()) {
-            Context.setForwardedEvent(true);
-            doIndex(id, indexEvent, retryCount);
-          } catch (Exception e) {
-            log.atWarning().withCause(e).log("Change %s could not be indexed", id);
-          }
-        },
-        retryInterval,
-        TimeUnit.MILLISECONDS);
-    return true;
-  }
-
   @Override
-  protected void doDelete(String id, Optional<IndexEvent> indexEvent) throws IOException {
+  protected CompletableFuture<Boolean> doDelete(String id, Optional<IndexEvent> indexEvent)
+      throws IOException {
     indexer.delete(parseChangeId(id));
     log.atFine().log("Change %s successfully deleted from index", id);
+    return CompletableFuture.completedFuture(true);
   }
 
   private static Change.Id parseChangeId(String id) {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandler.java
index ab31659..99ac369 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandler.java
@@ -19,6 +19,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * Index a group using {@link GroupIndexer}. This class is meant to be used on the receiving side of
@@ -36,13 +37,16 @@
   }
 
   @Override
-  protected void doIndex(AccountGroup.UUID uuid, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doIndex(
+      AccountGroup.UUID uuid, Optional<IndexEvent> indexEvent) {
     indexer.index(uuid);
     log.atFine().log("Group %s successfully indexed", uuid);
+    return CompletableFuture.completedFuture(true);
   }
 
   @Override
-  protected void doDelete(AccountGroup.UUID uuid, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doDelete(
+      AccountGroup.UUID uuid, Optional<IndexEvent> indexEvent) {
     throw new UnsupportedOperationException("Delete from group index not supported");
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandler.java
index 20ffefd..b16cb6e 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandler.java
@@ -19,6 +19,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * Index a project using {@link ProjectIndexer}. This class is meant to be used on the receiving
@@ -36,13 +37,16 @@
   }
 
   @Override
-  protected void doIndex(Project.NameKey projectName, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doIndex(
+      Project.NameKey projectName, Optional<IndexEvent> indexEvent) {
     indexer.index(projectName);
     log.atFine().log("Project %s successfully indexed", projectName);
+    return CompletableFuture.completedFuture(true);
   }
 
   @Override
-  protected void doDelete(Project.NameKey projectName, Optional<IndexEvent> indexEvent) {
+  protected CompletableFuture<Boolean> doDelete(
+      Project.NameKey projectName, Optional<IndexEvent> indexEvent) {
     throw new UnsupportedOperationException("Delete from project index not supported");
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexingHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexingHandler.java
index 44e35a8..6ce925a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexingHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexingHandler.java
@@ -19,6 +19,7 @@
 import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
@@ -41,9 +42,11 @@
     }
   }
 
-  protected abstract void doIndex(T id, Optional<IndexEvent> indexEvent) throws IOException;
+  protected abstract CompletableFuture<Boolean> doIndex(T id, Optional<IndexEvent> indexEvent)
+      throws IOException;
 
-  protected abstract void doDelete(T id, Optional<IndexEvent> indexEvent) throws IOException;
+  protected abstract CompletableFuture<Boolean> doDelete(T id, Optional<IndexEvent> indexEvent)
+      throws IOException;
 
   /**
    * Index an item in the local node, indexing will not be forwarded to the other node.
@@ -53,29 +56,27 @@
    * @param indexEvent The index event details.
    * @throws IOException If an error occur while indexing.
    */
-  public void index(T id, Operation operation, Optional<IndexEvent> indexEvent) throws IOException {
+  public CompletableFuture<Boolean> index(
+      T id, Operation operation, Optional<IndexEvent> indexEvent) throws IOException {
     log.atFine().log("%s %s %s", operation, id, indexEvent);
     if (inFlightIndexing.add(id)) {
       try {
         Context.setForwardedEvent(true);
         switch (operation) {
           case INDEX:
-            doIndex(id, indexEvent);
-            break;
+            return doIndex(id, indexEvent);
           case DELETE:
-            doDelete(id, indexEvent);
-            break;
+            return doDelete(id, indexEvent);
           default:
             log.atSevere().log("unexpected operation: %s", operation);
-            break;
+            return CompletableFuture.completedFuture(false);
         }
       } finally {
         Context.unsetForwardedEvent();
         inFlightIndexing.remove(id);
       }
-    } else {
-      throw new InFlightIndexedException(
-          String.format("Indexing for %s %s %s already in flight", operation, id, indexEvent));
     }
+    throw new InFlightIndexedException(
+        String.format("Indexing for %s %s %s already in flight", operation, id, indexEvent));
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/AddToProjectList.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/AddToProjectList.java
new file mode 100644
index 0000000..f7af2df
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/AddToProjectList.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+public class AddToProjectList extends Command {
+  static final String TYPE = "add-to-project-list";
+
+  private final String projectName;
+
+  public AddToProjectList(String projectName) {
+    super(TYPE);
+    this.projectName = projectName;
+  }
+
+  public String getProjectName() {
+    return projectName;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/Command.java
similarity index 62%
copy from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
copy to src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/Command.java
index 9b33ca3..a258d91 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/Command.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
 
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
+public class Command {
+  public final String type;
 
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface CacheExecutor {}
+  protected Command(String type) {
+    this.type = type;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializer.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializer.java
new file mode 100644
index 0000000..b75f409
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializer.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class CommandDeserializer implements JsonDeserializer<Command> {
+
+  private static final List<Class<? extends Command>> CMD_CLASSES =
+      List.of(
+          IndexChange.Update.class,
+          IndexChange.Delete.class,
+          IndexAccount.class,
+          IndexGroup.class,
+          IndexProject.class,
+          EvictCache.class,
+          PostEvent.class,
+          AddToProjectList.class,
+          RemoveFromProjectList.class);
+  private static final Map<String, Class<?>> COMMAND_TYPE_TO_CLASS_MAPPING = new HashMap<>();
+
+  static {
+    for (Class<?> clazz : CMD_CLASSES) {
+      try {
+        Field type = clazz.getDeclaredField("TYPE");
+        COMMAND_TYPE_TO_CLASS_MAPPING.put((String) type.get(null), clazz);
+      } catch (Exception e) {
+        throw new RuntimeException(e);
+      }
+    }
+  }
+
+  @Override
+  public Command deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.isJsonObject()) {
+      throw new JsonParseException("Not an object");
+    }
+    JsonElement typeJson = json.getAsJsonObject().get("type");
+    if (typeJson == null
+        || !typeJson.isJsonPrimitive()
+        || !typeJson.getAsJsonPrimitive().isString()) {
+      throw new JsonParseException("Type is not a string: " + typeJson);
+    }
+    String type = typeJson.getAsJsonPrimitive().getAsString();
+    Class<?> commandClass = COMMAND_TYPE_TO_CLASS_MAPPING.get(type);
+    if (commandClass == null) {
+      throw new JsonParseException("Unknown command type: " + type);
+    }
+    return context.deserialize(json, commandClass);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/EvictCache.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/EvictCache.java
new file mode 100644
index 0000000..c4ae6d0
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/EvictCache.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+public class EvictCache extends Command {
+  static final String TYPE = "evict-cache";
+
+  private final String cacheName;
+  private final String keyJson;
+
+  protected EvictCache(String cacheName, String keyJson) {
+    super(TYPE);
+    this.cacheName = cacheName;
+    this.keyJson = keyJson;
+  }
+
+  public String getCacheName() {
+    return cacheName;
+  }
+
+  public String getKeyJson() {
+    return keyJson;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/FailsafeExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/FailsafeExecutorProvider.java
new file mode 100644
index 0000000..8f81832
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/FailsafeExecutorProvider.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import dev.failsafe.Failsafe;
+import dev.failsafe.FailsafeExecutor;
+import dev.failsafe.RetryPolicy;
+
+public class FailsafeExecutorProvider implements Provider<FailsafeExecutor<Boolean>> {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+  private final Configuration cfg;
+  private final WorkQueue workQueue;
+
+  @Inject
+  FailsafeExecutorProvider(Configuration cfg, WorkQueue workQueue) {
+    this.cfg = cfg;
+    this.workQueue = workQueue;
+  }
+
+  @Override
+  public FailsafeExecutor<Boolean> get() {
+    RetryPolicy<Boolean> retryPolicy =
+        RetryPolicy.<Boolean>builder()
+            .withMaxAttempts(cfg.jgroups().maxTries())
+            .withDelay(cfg.jgroups().retryInterval())
+            .onRetry(e -> log.atFine().log("Retrying event %s", e))
+            .onRetriesExceeded(
+                e ->
+                    log.atWarning().log(
+                        "%d jgroups retries exceeded for event %s", cfg.jgroups().maxTries(), e))
+            .handleResult(false)
+            .build();
+    return Failsafe.with(retryPolicy)
+        .with(workQueue.createQueue(cfg.jgroups().threadPoolSize(), "JGroupsForwarder"));
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexAccount.java
similarity index 60%
copy from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
copy to src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexAccount.java
index 9b33ca3..7d9bbc4 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexAccount.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
 
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
+public class IndexAccount extends Command {
+  static final String TYPE = "index-account";
 
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
+  private final int id;
 
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface CacheExecutor {}
+  public IndexAccount(int id) {
+    super(TYPE);
+    this.id = id;
+  }
+
+  public int getId() {
+    return id;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexChange.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexChange.java
new file mode 100644
index 0000000..501a722
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexChange.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.common.base.Strings;
+
+public abstract class IndexChange extends Command {
+  private final String projectName;
+  private final int id;
+  private final boolean batchMode;
+
+  protected IndexChange(String type, String projectName, int id, boolean batchMode) {
+    super(type);
+    this.projectName = projectName;
+    this.id = id;
+    this.batchMode = batchMode;
+  }
+
+  public String getId() {
+    return Strings.nullToEmpty(projectName) + "~" + id;
+  }
+
+  public boolean isBatch() {
+    return batchMode;
+  }
+
+  public static class Update extends IndexChange {
+    static final String TYPE = "update-change";
+
+    public Update(String projectName, int id) {
+      this(projectName, id, false);
+    }
+
+    public Update(String projectName, int id, boolean batchMode) {
+      super(TYPE, projectName, id, batchMode);
+    }
+  }
+
+  public static class Delete extends IndexChange {
+    static final String TYPE = "delete-change";
+
+    public Delete(int id) {
+      this("", id);
+    }
+
+    public Delete(String projectName, int id) {
+      super(TYPE, projectName, id, false);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexGroup.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexGroup.java
new file mode 100644
index 0000000..b5c9f78
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexGroup.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+public class IndexGroup extends Command {
+  static final String TYPE = "index-group";
+
+  private final String uuid;
+
+  protected IndexGroup(String uuid) {
+    super(TYPE);
+    this.uuid = uuid;
+  }
+
+  public String getUuid() {
+    return uuid;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexProject.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexProject.java
new file mode 100644
index 0000000..4d8214d
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/IndexProject.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+public class IndexProject extends Command {
+  static final String TYPE = "index-project";
+
+  private String projectName;
+
+  protected IndexProject(String projectName) {
+    super(TYPE);
+    this.projectName = projectName;
+  }
+
+  public String getProjectName() {
+    return projectName;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
new file mode 100644
index 0000000..40a358e
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.Configuration.JGroups;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.events.Event;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.failsafe.FailsafeExecutor;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.concurrent.CompletableFuture;
+import org.jgroups.Address;
+import org.jgroups.ObjectMessage;
+import org.jgroups.blocks.MessageDispatcher;
+import org.jgroups.blocks.RequestOptions;
+import org.jgroups.blocks.ResponseMode;
+import org.jgroups.util.Rsp;
+import org.jgroups.util.RspList;
+
+@Singleton
+public class JGroupsForwarder implements Forwarder {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  private final MessageDispatcher dispatcher;
+  private final JGroups jgroupsConfig;
+  private final Gson gson;
+  private final FailsafeExecutor<Boolean> executor;
+
+  @Inject
+  JGroupsForwarder(
+      MessageDispatcher dispatcher,
+      Configuration cfg,
+      @JGroupsGson Gson gson,
+      @JGroupsForwarderExecutor FailsafeExecutor<Boolean> executor) {
+    this.dispatcher = dispatcher;
+    this.jgroupsConfig = cfg.jgroups();
+    this.gson = gson;
+    this.executor = executor;
+  }
+
+  @Override
+  public CompletableFuture<Boolean> indexAccount(int accountId, IndexEvent indexEvent) {
+    return execute(new IndexAccount(accountId));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> indexChange(
+      String projectName, int changeId, IndexEvent indexEvent) {
+    return execute(new IndexChange.Update(projectName, changeId));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> batchIndexChange(
+      String projectName, int changeId, IndexEvent indexEvent) {
+    return execute(new IndexChange.Update(projectName, changeId, true));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> deleteChangeFromIndex(int changeId, IndexEvent indexEvent) {
+    return execute(new IndexChange.Delete(changeId));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> indexGroup(String uuid, IndexEvent indexEvent) {
+    return execute(new IndexGroup(uuid));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> indexProject(String projectName, IndexEvent indexEvent) {
+    return execute(new IndexProject(projectName));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> send(Event event) {
+    return execute(new PostEvent(event));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> evict(String cacheName, Object key) {
+    return execute(new EvictCache(cacheName, gson.toJson(key)));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> addToProjectList(String projectName) {
+    return execute(new AddToProjectList(projectName));
+  }
+
+  @Override
+  public CompletableFuture<Boolean> removeFromProjectList(String projectName) {
+    return execute(new RemoveFromProjectList(projectName));
+  }
+
+  private CompletableFuture<Boolean> execute(Command cmd) {
+    return executor.getAsync(() -> executeOnce(cmd));
+  }
+
+  private boolean executeOnce(Command cmd) {
+    String json = gson.toJson(cmd);
+    try {
+      logJGroupsInfo();
+
+      if (dispatcher.getChannel().getView().size() < 2) {
+        log.atFine().log("Less than two members in cluster, not sending %s", json);
+        return false;
+      }
+
+      log.atFine().log("Sending %s", json);
+      RequestOptions options =
+          new RequestOptions(ResponseMode.GET_FIRST, jgroupsConfig.timeout().toMillis());
+      RspList<Object> list = dispatcher.castMessage(null, new ObjectMessage(null, json), options);
+
+      log.atFine().log("Received response list length = %s", list.size());
+      if (list.isEmpty()) {
+        return false;
+      }
+
+      for (Entry<Address, Rsp<Object>> e : list.entrySet()) {
+        log.atFine().log("Response object %s", e);
+        if (!Boolean.TRUE.equals(e.getValue().getValue())) {
+          log.atWarning().log(
+              "Received a non TRUE response from receiver %s: %s",
+              e.getKey(), e.getValue().getValue());
+          return false;
+        }
+      }
+      log.atFine().log("Successfully sent message %s", json);
+      return true;
+    } catch (Exception e) {
+      log.atWarning().withCause(e).log("Forwarding %s failed", json);
+      return false;
+    }
+  }
+
+  private void logJGroupsInfo() {
+    log.atFine().log("My address: %s", dispatcher.getChannel().getAddress());
+    List<Address> members = dispatcher.getChannel().getView().getMembers();
+    for (Address m : members) {
+      log.atFine().log("Member: %s", m);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderExecutor.java
similarity index 80%
copy from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
copy to src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderExecutor.java
index 9b33ca3..6d2bedf 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
-@Retention(RUNTIME)
 @BindingAnnotation
-@interface CacheExecutor {}
+@Retention(RUNTIME)
+public @interface JGroupsForwarderExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderModule.java
new file mode 100644
index 0000000..58e7aeb
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderModule.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.JChannelProviderModule;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.events.EventGson;
+import com.google.gson.Gson;
+import com.google.inject.Provides;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import dev.failsafe.FailsafeExecutor;
+import org.jgroups.blocks.MessageDispatcher;
+import org.jgroups.blocks.RequestHandler;
+
+public class JGroupsForwarderModule extends LifecycleModule {
+
+  @Override
+  protected void configure() {
+    bind(Forwarder.class).to(JGroupsForwarder.class);
+    bind(MessageDispatcher.class).toProvider(MessageDispatcherProvider.class).in(Scopes.SINGLETON);
+    bind(RequestHandler.class).to(MessageProcessor.class);
+    install(new JChannelProviderModule());
+    listener().to(OnStartStop.class);
+
+    bind(new TypeLiteral<FailsafeExecutor<Boolean>>() {})
+        .annotatedWith(JGroupsForwarderExecutor.class)
+        .toProvider(FailsafeExecutorProvider.class)
+        .in(Scopes.SINGLETON);
+  }
+
+  @Provides
+  @Singleton
+  @JGroupsGson
+  Gson buildJGroupsGson(@EventGson Gson eventGson) {
+    return eventGson
+        .newBuilder()
+        .registerTypeAdapter(Command.class, new CommandDeserializer())
+        .create();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsGson.java
similarity index 81%
rename from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
rename to src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsGson.java
index 9b33ca3..a4ca15a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsGson.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
-@Retention(RUNTIME)
 @BindingAnnotation
-@interface CacheExecutor {}
+@Retention(RUNTIME)
+public @interface JGroupsGson {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageDispatcherProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageDispatcherProvider.java
new file mode 100644
index 0000000..495ea8f
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageDispatcherProvider.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.Configuration.JGroups;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.InetAddressFinder;
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.util.Optional;
+import org.jgroups.JChannel;
+import org.jgroups.blocks.MessageDispatcher;
+import org.jgroups.blocks.RequestHandler;
+
+@Singleton
+public class MessageDispatcherProvider implements Provider<MessageDispatcher> {
+  private static FluentLogger log = FluentLogger.forEnclosingClass();
+
+  private final InetAddressFinder finder;
+  private final JGroups jgroupsConfig;
+  private final RequestHandler requestHandler;
+  private final JChannel channel;
+
+  @Inject
+  MessageDispatcherProvider(
+      InetAddressFinder finder,
+      Configuration pluginConfiguration,
+      RequestHandler requestHandler,
+      JChannel channel) {
+    this.finder = finder;
+    this.jgroupsConfig = pluginConfiguration.jgroups();
+    this.requestHandler = requestHandler;
+    this.channel = channel;
+  }
+
+  @Override
+  public MessageDispatcher get() {
+    try {
+      Optional<InetAddress> address = finder.findAddress();
+      if (address.isPresent()) {
+        channel.getProtocolStack().getTransport().setBindAddress(address.get());
+      }
+      channel.setDiscardOwnMessages(true);
+      channel.connect(jgroupsConfig.clusterName());
+      log.atInfo().log("Succesfully joined jgroups cluster %s", channel.getClusterName());
+      MessageDispatcher dispatcher = new MessageDispatcher(channel, requestHandler);
+      return dispatcher;
+    } catch (Exception e) {
+      throw new ProvisionException("Could not create a JChannel", e);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessor.java
new file mode 100644
index 0000000..54096ac
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessor.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.forwarder.CacheEntry;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedCacheEvictionHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedEventHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexAccountHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexBatchChangeHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexChangeHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexingHandler.Operation;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedProjectListUpdateHandler;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.jgroups.Message;
+import org.jgroups.blocks.RequestHandler;
+
+@Singleton
+public class MessageProcessor implements RequestHandler {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  private final Gson gson;
+  private final ForwardedIndexChangeHandler indexChangeHandler;
+  private final ForwardedIndexBatchChangeHandler indexBatchChangeHandler;
+  private final ForwardedIndexAccountHandler indexAccountHandler;
+  private final ForwardedCacheEvictionHandler cacheEvictionHandler;
+  private final ForwardedEventHandler eventHandler;
+  private final ForwardedProjectListUpdateHandler projectListUpdateHandler;
+
+  @Inject
+  MessageProcessor(
+      @JGroupsGson Gson gson,
+      ForwardedIndexChangeHandler indexChangeHandler,
+      ForwardedIndexBatchChangeHandler indexBatchChangeHandler,
+      ForwardedIndexAccountHandler indexAccountHandler,
+      ForwardedCacheEvictionHandler cacheEvictionHandler,
+      ForwardedEventHandler eventHandler,
+      ForwardedProjectListUpdateHandler projectListUpdateHandler) {
+    this.gson = gson;
+    this.indexChangeHandler = indexChangeHandler;
+    this.indexBatchChangeHandler = indexBatchChangeHandler;
+    this.indexAccountHandler = indexAccountHandler;
+    this.cacheEvictionHandler = cacheEvictionHandler;
+    this.eventHandler = eventHandler;
+    this.projectListUpdateHandler = projectListUpdateHandler;
+  }
+
+  @Override
+  public Object handle(Message msg) {
+    Command cmd = getCommand(msg);
+
+    Context.setForwardedEvent(true);
+    try {
+
+      if (cmd instanceof IndexChange) {
+        IndexChange indexChange = (IndexChange) cmd;
+        Operation op = getOperation(indexChange);
+        try {
+          ForwardedIndexChangeHandler handler =
+              indexChange.isBatch() ? indexBatchChangeHandler : indexChangeHandler;
+          handler.index(indexChange.getId(), op, Optional.empty());
+          log.atFine().log(
+              "Change index %s on change %s done", op.name().toLowerCase(), indexChange.getId());
+        } catch (Exception e) {
+          log.atSevere().withCause(e).log(
+              "Change index %s on change %s failed", op.name().toLowerCase(), indexChange.getId());
+          return false;
+        }
+
+      } else if (cmd instanceof IndexAccount) {
+        IndexAccount indexAccount = (IndexAccount) cmd;
+        try {
+          indexAccountHandler.index(
+              Account.id(indexAccount.getId()), Operation.INDEX, Optional.empty());
+          log.atFine().log("Account index update on account %s done", indexAccount.getId());
+        } catch (IOException e) {
+          log.atSevere().withCause(e).log(
+              "Account index update on account %s failed", indexAccount.getId());
+          return false;
+        }
+
+      } else if (cmd instanceof EvictCache) {
+        EvictCache evictCommand = (EvictCache) cmd;
+        cacheEvictionHandler.evict(
+            CacheEntry.from(evictCommand.getCacheName(), evictCommand.getKeyJson()));
+        log.atFine().log(
+            "Cache eviction %s %s done", evictCommand.getCacheName(), evictCommand.getKeyJson());
+
+      } else if (cmd instanceof PostEvent) {
+        Event event = ((PostEvent) cmd).getEvent();
+        try {
+          eventHandler.dispatch(event);
+          log.atFine().log("Dispatching event %s done", event);
+        } catch (PermissionBackendException e) {
+          log.atSevere().withCause(e).log("Dispatching event %s failed", event);
+          return false;
+        }
+
+      } else if (cmd instanceof AddToProjectList) {
+        String projectName = ((AddToProjectList) cmd).getProjectName();
+        projectListUpdateHandler.update(projectName, false);
+
+      } else if (cmd instanceof RemoveFromProjectList) {
+        String projectName = ((RemoveFromProjectList) cmd).getProjectName();
+        projectListUpdateHandler.update(projectName, true);
+      }
+
+      return true;
+    } catch (Exception e) {
+      return false;
+    } finally {
+      Context.unsetForwardedEvent();
+    }
+  }
+
+  private Operation getOperation(IndexChange cmd) {
+    if (cmd instanceof IndexChange.Update) {
+      return Operation.INDEX;
+    } else if (cmd instanceof IndexChange.Delete) {
+      return Operation.DELETE;
+    } else {
+      throw new IllegalArgumentException("Unknown type of IndexChange command " + cmd.getClass());
+    }
+  }
+
+  private Command getCommand(Message msg) {
+    try {
+      String s = (String) msg.getObject();
+      log.atFine().log("Received message: %s", s);
+      return gson.fromJson(s, Command.class);
+    } catch (RuntimeException e) {
+      log.atSevere().withCause(e).log("Error parsing message %s", msg.getObject());
+      throw e;
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/OnStartStop.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/OnStartStop.java
new file mode 100644
index 0000000..fad84f2
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/OnStartStop.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.jgroups.blocks.MessageDispatcher;
+
+@Singleton
+public class OnStartStop implements LifecycleListener {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  private final MessageDispatcher dispatcher;
+
+  @Inject
+  public OnStartStop(MessageDispatcher dispatcher) {
+    this.dispatcher = dispatcher;
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    log.atInfo().log("Closing JChannel");
+    dispatcher.getChannel().close();
+    try {
+      dispatcher.close();
+    } catch (IOException e) {
+      log.atSevere().withCause(e).log("Could not close the MessageDispatcher");
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/PostEvent.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/PostEvent.java
new file mode 100644
index 0000000..49627a7
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/PostEvent.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.gerrit.server.events.Event;
+
+public class PostEvent extends Command {
+  static final String TYPE = "post-event";
+
+  private final Event event;
+
+  protected PostEvent(Event event) {
+    super(TYPE);
+    this.event = event;
+  }
+
+  public Event getEvent() {
+    return event;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/RemoveFromProjectList.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/RemoveFromProjectList.java
new file mode 100644
index 0000000..8eb0a3d
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/RemoveFromProjectList.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+public class RemoveFromProjectList extends Command {
+  static final String TYPE = "remove-from-project-list";
+
+  private final String projectName;
+
+  public RemoveFromProjectList(String projectName) {
+    super(TYPE);
+    this.projectName = projectName;
+  }
+
+  public String getProjectName() {
+    return projectName;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheKeyJsonParser.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheKeyJsonParser.java
index 48f3bff..ba28650 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheKeyJsonParser.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/CacheKeyJsonParser.java
@@ -28,7 +28,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class CacheKeyJsonParser {
+public class CacheKeyJsonParser {
   private final Gson gson;
 
   @Inject
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/FailsafeExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/FailsafeExecutorProvider.java
new file mode 100644
index 0000000..8134220
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/FailsafeExecutorProvider.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import dev.failsafe.Failsafe;
+import dev.failsafe.FailsafeExecutor;
+import dev.failsafe.Fallback;
+import dev.failsafe.RetryPolicy;
+import java.util.concurrent.Executors;
+
+@Singleton
+public class FailsafeExecutorProvider implements Provider<FailsafeExecutor<Boolean>> {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+  private final Configuration cfg;
+
+  @Inject
+  FailsafeExecutorProvider(Configuration cfg) {
+    this.cfg = cfg;
+  }
+
+  @Override
+  public FailsafeExecutor<Boolean> get() {
+    Fallback<Boolean> fallbackToFalse = Fallback.<Boolean>of(() -> false);
+    RetryPolicy<Boolean> retryPolicy =
+        RetryPolicy.<Boolean>builder()
+            .withMaxAttempts(cfg.http().maxTries())
+            .withDelay(cfg.http().retryInterval())
+            .onRetry(e -> log.atFine().log("Retrying event %s", e))
+            .onRetriesExceeded(
+                e ->
+                    log.atWarning().log(
+                        "%d http retries exceeded for event %s", cfg.http().maxTries(), e))
+            .handleResult(false)
+            .abortIf(
+                (r, e) ->
+                    e instanceof ForwardingException && !((ForwardingException) e).isRecoverable())
+            .build();
+    // TODO: the executor shall be created by workQueue.createQueue(...)
+    //   However, this currently doesn't work because WorkQueue.Executor doesn't support wrapping of
+    //   Callable i.e. it throws an exception on decorateTask(Callable)
+    return Failsafe.with(fallbackToFalse, retryPolicy)
+        .with(Executors.newScheduledThreadPool(cfg.http().threadPoolSize()));
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProvider.java
index 0510aed..a71e03f 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProvider.java
@@ -68,10 +68,11 @@
   }
 
   private RequestConfig customRequestConfig() {
+    int connectionTimeout = (int) cfg.http().connectionTimeout().toMillis();
     return RequestConfig.custom()
-        .setConnectTimeout(cfg.http().connectionTimeout())
-        .setSocketTimeout(cfg.http().socketTimeout())
-        .setConnectionRequestTimeout(cfg.http().connectionTimeout())
+        .setConnectTimeout(connectionTimeout)
+        .setSocketTimeout((int) cfg.http().socketTimeout().toMillis())
+        .setConnectionRequestTimeout(connectionTimeout)
         .build();
   }
 
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
index c0eb6ae..922fbd6 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
@@ -29,6 +29,7 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import dev.failsafe.FailsafeExecutor;
 import java.io.IOException;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -49,7 +50,7 @@
   private final Configuration cfg;
   private final Provider<Set<PeerInfo>> peerInfoProvider;
   private final Gson gson;
-  private final RestForwarderScheduler scheduler;
+  private FailsafeExecutor<Boolean> executor;
 
   @Inject
   RestForwarder(
@@ -58,13 +59,13 @@
       Configuration cfg,
       Provider<Set<PeerInfo>> peerInfoProvider,
       @EventGson Gson gson,
-      RestForwarderScheduler scheduler) {
+      @RestForwarderExecutor FailsafeExecutor<Boolean> executor) {
     this.httpSession = httpClient;
     this.pluginRelativePath = Joiner.on("/").join("plugins", pluginName);
     this.cfg = cfg;
     this.peerInfoProvider = peerInfoProvider;
     this.gson = gson;
-    this.scheduler = scheduler;
+    this.executor = executor;
   }
 
   @Override
@@ -162,7 +163,7 @@
       RequestMethod method, String action, String endpoint, Object id, Object payload) {
     return peerInfoProvider.get().stream()
         .map(peer -> createRequest(method, peer, action, endpoint, id, payload))
-        .map(scheduler::execute)
+        .map(r -> executor.getAsync(() -> r.execute()))
         .reduce(
             CompletableFuture.completedFuture(true),
             (a, b) -> a.thenCombine(b, (left, right) -> left && right));
@@ -226,12 +227,6 @@
               action, key, destination);
           throw e;
         }
-        if (execCnt >= maxTries) {
-          log.atSevere().log(
-              "Failed to %s %s on %s after %d tries; giving up",
-              action, key, destination, maxTries);
-          throw e;
-        }
       }
       return false;
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderExecutor.java
similarity index 81%
copy from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
copy to src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderExecutor.java
index 9b33ca3..c8dd34f 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
-@Retention(RUNTIME)
 @BindingAnnotation
-@interface CacheExecutor {}
+@Retention(RUNTIME)
+public @interface RestForwarderExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderModule.java
index e535d0f..39c9cc0 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderModule.java
@@ -17,6 +17,8 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.google.inject.AbstractModule;
 import com.google.inject.Scopes;
+import com.google.inject.TypeLiteral;
+import dev.failsafe.FailsafeExecutor;
 import org.apache.http.impl.client.CloseableHttpClient;
 
 public class RestForwarderModule extends AbstractModule {
@@ -26,5 +28,10 @@
     bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class).in(Scopes.SINGLETON);
     bind(HttpSession.class);
     bind(Forwarder.class).to(RestForwarder.class);
+
+    bind(new TypeLiteral<FailsafeExecutor<Boolean>>() {})
+        .annotatedWith(RestForwarderExecutor.class)
+        .toProvider(FailsafeExecutorProvider.class)
+        .in(Scopes.SINGLETON);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderScheduler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderScheduler.java
deleted file mode 100644
index ebbd940..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderScheduler.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-@Singleton
-public class RestForwarderScheduler {
-  private static final FluentLogger log = FluentLogger.forEnclosingClass();
-  private final ScheduledExecutorService executor;
-  private final long retryIntervalMs;
-
-  public class CompletablePromise<V> extends CompletableFuture<V> {
-    private Future<V> future;
-
-    public CompletablePromise(Future<V> future) {
-      this.future = future;
-      executor.execute(this::tryToComplete);
-    }
-
-    private void tryToComplete() {
-      if (future.isDone()) {
-        try {
-          complete(future.get());
-        } catch (InterruptedException e) {
-          completeExceptionally(e);
-        } catch (ExecutionException e) {
-          completeExceptionally(e.getCause());
-        }
-        return;
-      }
-
-      if (future.isCancelled()) {
-        cancel(true);
-        return;
-      }
-
-      executor.execute(this::tryToComplete);
-    }
-  }
-
-  @Inject
-  public RestForwarderScheduler(
-      WorkQueue workQueue, Configuration cfg, Provider<Set<PeerInfo>> peerInfoProvider) {
-    int executorSize = peerInfoProvider.get().size() * cfg.index().threadPoolSize();
-    retryIntervalMs = cfg.index().retryInterval();
-    this.executor = workQueue.createQueue(executorSize, "RestForwarderScheduler");
-  }
-
-  @VisibleForTesting
-  public RestForwarderScheduler(ScheduledExecutorService executor) {
-    this.executor = executor;
-    retryIntervalMs = 0;
-  }
-
-  public CompletableFuture<Boolean> execute(RestForwarder.Request request) {
-    return execute(request, 0);
-  }
-
-  public CompletableFuture<Boolean> execute(RestForwarder.Request request, long delayMs) {
-    return supplyAsync(
-        request.toString(),
-        () -> {
-          try {
-            if (!request.execute()) {
-              log.atWarning().log(
-                  "Rescheduling %s for retry after %d msec", request, retryIntervalMs);
-              return execute(request, retryIntervalMs);
-            }
-            return CompletableFuture.completedFuture(true);
-          } catch (ForwardingException e) {
-            log.atSevere().withCause(e).log("Forwarding of %s has failed", request);
-            return CompletableFuture.completedFuture(false);
-          }
-        },
-        executor,
-        delayMs);
-  }
-
-  private CompletableFuture<Boolean> supplyAsync(
-      String taskName,
-      Supplier<CompletableFuture<Boolean>> fn,
-      ScheduledExecutorService executor,
-      long delayMs) {
-    BooleanAsyncSupplier asyncSupplier = new BooleanAsyncSupplier(taskName, fn);
-    executor.schedule(asyncSupplier, delayMs, TimeUnit.MILLISECONDS);
-    return asyncSupplier.future();
-  }
-
-  static class BooleanAsyncSupplier implements Runnable {
-    private CompletableFuture<CompletableFuture<Boolean>> dep;
-    private Supplier<CompletableFuture<Boolean>> fn;
-    private String taskName;
-
-    BooleanAsyncSupplier(String taskName, Supplier<CompletableFuture<Boolean>> fn) {
-      this.taskName = taskName;
-      this.dep = new CompletableFuture<>();
-      this.fn = fn;
-    }
-
-    public CompletableFuture<Boolean> future() {
-      return dep.thenCompose(Function.identity());
-    }
-
-    @Override
-    public void run() {
-      try {
-        dep.complete(fn.get());
-      } catch (Throwable ex) {
-        dep.completeExceptionally(ex);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return taskName;
-    }
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutor.java
deleted file mode 100644
index 06da9f0..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutor.java
+++ /dev/null
@@ -1,24 +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.index;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface BatchIndexExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImpl.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImpl.java
index 252b7e8..af730b7 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImpl.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImpl.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -38,7 +38,7 @@
 public class ChangeCheckerImpl implements ChangeChecker {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
   private final GitRepositoryManager gitRepoMgr;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final OneOffRequestContext oneOffReqCtx;
   private final String changeId;
   private final ChangeFinder changeFinder;
@@ -52,13 +52,13 @@
   @Inject
   public ChangeCheckerImpl(
       GitRepositoryManager gitRepoMgr,
-      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       ChangeFinder changeFinder,
       OneOffRequestContext oneOffReqCtx,
       @Assisted String changeId) {
     this.changeFinder = changeFinder;
     this.gitRepoMgr = gitRepoMgr;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.oneOffReqCtx = oneOffReqCtx;
     this.changeId = changeId;
   }
@@ -189,7 +189,8 @@
   private long getTsFromChangeAndDraftComments(ChangeNotes notes) {
     Change change = notes.getChange();
     Timestamp changeTs = Timestamp.from(change.getLastUpdatedOn());
-    for (HumanComment comment : commentsUtil.draftByChange(changeNotes.get())) {
+    for (HumanComment comment :
+        draftCommentsReader.getDraftsByChangeForAllAuthors(changeNotes.get())) {
       Timestamp commentTs = comment.writtenOn;
       changeTs = commentTs.after(changeTs) ? commentTs : changeTs;
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexExecutorProvider.java
index f5bc85e..a3a7e64 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexExecutorProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexExecutorProvider.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2021 The Android Open Source Project
+// 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.
@@ -16,6 +16,7 @@
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -23,8 +24,13 @@
 @Singleton
 class ForwardedBatchIndexExecutorProvider extends ExecutorProvider {
 
+  @UsedAt(UsedAt.Project.PLUGIN_MULTI_SITE)
+  public static final String FORWARDED_BATCH_INDEX_EVENT_THREAD_PREFIX =
+      "Forwarded-BatchIndex-Event";
+
   @Inject
   ForwardedBatchIndexExecutorProvider(WorkQueue workQueue, Configuration config) {
-    super(workQueue, config.index().batchThreadPoolSize(), "Forwarded-BatchIndex-Event");
+    super(
+        workQueue, config.index().batchThreadPoolSize(), FORWARDED_BATCH_INDEX_EVENT_THREAD_PREFIX);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexFailsafeExecutorProvider.java
similarity index 76%
rename from src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutorProvider.java
rename to src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexFailsafeExecutorProvider.java
index cfbd4fb..f52b3fe 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/BatchIndexExecutorProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedBatchIndexFailsafeExecutorProvider.java
@@ -16,15 +16,15 @@
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-class BatchIndexExecutorProvider extends ExecutorProvider {
+class ForwardedBatchIndexFailsafeExecutorProvider extends ForwardedIndexFailsafeExecutorProvider {
 
   @Inject
-  BatchIndexExecutorProvider(WorkQueue workQueue, Configuration config) {
-    super(workQueue, config.index().batchThreadPoolSize(), "Forward-BatchIndex-Event");
+  ForwardedBatchIndexFailsafeExecutorProvider(
+      Configuration cfg, @ForwardedBatchIndexExecutor ExecutorProvider indexExecutorProvider) {
+    super(cfg, indexExecutorProvider);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexExecutorProvider.java
index 2112dbe..da623df 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexExecutorProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexExecutorProvider.java
@@ -16,15 +16,19 @@
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-class ForwardedIndexExecutorProvider extends ExecutorProvider {
+public class ForwardedIndexExecutorProvider extends ExecutorProvider {
+
+  @UsedAt(UsedAt.Project.PLUGIN_MULTI_SITE)
+  public static final String FORWARDED_INDEX_EVENT_THREAD_PREFIX = "Forwarded-Index-Event";
 
   @Inject
   ForwardedIndexExecutorProvider(WorkQueue workQueue, Configuration config) {
-    super(workQueue, config.index().threadPoolSize(), "Forwarded-Index-Event");
+    super(workQueue, config.index().threadPoolSize(), FORWARDED_INDEX_EVENT_THREAD_PREFIX);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexFailsafeExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexFailsafeExecutorProvider.java
new file mode 100644
index 0000000..7bae94a
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/ForwardedIndexFailsafeExecutorProvider.java
@@ -0,0 +1,57 @@
+// 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.index;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import dev.failsafe.Failsafe;
+import dev.failsafe.FailsafeExecutor;
+import dev.failsafe.RetryPolicy;
+import java.io.IOException;
+
+@Singleton
+public class ForwardedIndexFailsafeExecutorProvider implements Provider<FailsafeExecutor<Boolean>> {
+  protected static final FluentLogger log = FluentLogger.forEnclosingClass();
+  private final Configuration cfg;
+  private final ExecutorProvider indexExecutorProvider;
+
+  @Inject
+  public ForwardedIndexFailsafeExecutorProvider(
+      Configuration cfg, @ForwardedIndexExecutor ExecutorProvider indexExecutorProvider) {
+    this.cfg = cfg;
+    this.indexExecutorProvider = indexExecutorProvider;
+  }
+
+  @Override
+  public FailsafeExecutor<Boolean> get() {
+    RetryPolicy<Boolean> retryPolicy =
+        RetryPolicy.<Boolean>builder()
+            .withMaxAttempts(cfg.index().maxTries())
+            .withDelay(cfg.index().retryInterval())
+            .onRetry(e -> log.atFine().log("Retrying event %s", e))
+            .onRetriesExceeded(
+                e ->
+                    log.atWarning().log(
+                        "%d index retries exceeded for event %s", cfg.index().maxTries(), e))
+            .handleResult(false)
+            .abortOn(IOException.class)
+            .build();
+    return Failsafe.with(retryPolicy).with(indexExecutorProvider.get());
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
index 4f77508..ec2a091 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
@@ -14,24 +14,16 @@
 
 package com.ericsson.gerrit.plugins.highavailability.index;
 
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
-import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.annotations.PluginName;
 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.inject.Inject;
-import java.util.Collections;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
+import java.util.Optional;
 
 class IndexEventHandler
     implements ChangeIndexedListener,
@@ -39,37 +31,16 @@
         GroupIndexedListener,
         ProjectIndexedListener {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
-  private final ScheduledExecutorService executor;
-  private final ScheduledExecutorService batchExecutor;
   private final Forwarder forwarder;
-  private final String pluginName;
-  private final Set<IndexTask> queuedTasks = Collections.newSetFromMap(new ConcurrentHashMap<>());
   private final ChangeCheckerImpl.Factory changeChecker;
   private final CurrentRequestContext currCtx;
-  private final IndexEventLocks locks;
-
-  private final int retryInterval;
-  private final int maxTries;
 
   @Inject
   IndexEventHandler(
-      @IndexExecutor ScheduledExecutorService executor,
-      @BatchIndexExecutor ScheduledExecutorService batchExecutor,
-      @PluginName String pluginName,
-      Forwarder forwarder,
-      ChangeCheckerImpl.Factory changeChecker,
-      CurrentRequestContext currCtx,
-      Configuration cfg,
-      IndexEventLocks locks) {
+      Forwarder forwarder, ChangeCheckerImpl.Factory changeChecker, CurrentRequestContext currCtx) {
     this.forwarder = forwarder;
-    this.executor = executor;
-    this.batchExecutor = batchExecutor;
-    this.pluginName = pluginName;
     this.changeChecker = changeChecker;
     this.currCtx = currCtx;
-    this.locks = locks;
-    this.retryInterval = cfg.http().retryInterval();
-    this.maxTries = cfg.http().maxTries();
   }
 
   @Override
@@ -77,10 +48,7 @@
     currCtx.onlyWithContext(
         (ctx) -> {
           if (!Context.isForwardedEvent()) {
-            IndexAccountTask task = new IndexAccountTask(id);
-            if (queuedTasks.add(task)) {
-              executor.execute(task);
-            }
+            forwarder.indexAccount(id, new IndexEvent());
           }
         });
   }
@@ -91,30 +59,19 @@
   }
 
   private void executeIndexChangeTask(String projectName, int id) {
-
     if (!Context.isForwardedEvent()) {
       String changeId = projectName + "~" + id;
       try {
-        changeChecker
-            .create(changeId)
-            .newIndexEvent()
-            .map(
-                event -> {
-                  if (Thread.currentThread().getName().contains("Batch")) {
-                    return new BatchIndexChangeTask(projectName, id, event);
-                  }
-                  return new IndexChangeTask(projectName, id, event);
-                })
-            .ifPresent(
-                task -> {
-                  if (queuedTasks.add(task)) {
-                    if (task instanceof BatchIndexChangeTask) {
-                      batchExecutor.execute(task);
-                    } else {
-                      executor.execute(task);
-                    }
-                  }
-                });
+        Optional<IndexEvent> indexEvent = changeChecker.create(changeId).newIndexEvent();
+        if (indexEvent.isEmpty()) {
+          return;
+        }
+
+        if (Thread.currentThread().getName().contains("Batch")) {
+          forwarder.batchIndexChange(projectName, id, indexEvent.get());
+        } else {
+          forwarder.indexChange(projectName, id, indexEvent.get());
+        }
       } catch (Exception e) {
         log.atWarning().withCause(e).log("Unable to create task to reindex change %s", changeId);
       }
@@ -124,296 +81,21 @@
   @Override
   public void onChangeDeleted(int id) {
     if (!Context.isForwardedEvent()) {
-      DeleteChangeTask task = new DeleteChangeTask(id, new IndexEvent());
-      if (queuedTasks.add(task)) {
-        executor.execute(task);
-      }
+      forwarder.deleteChangeFromIndex(id, new IndexEvent());
     }
   }
 
   @Override
   public void onProjectIndexed(String projectName) {
     if (!Context.isForwardedEvent()) {
-      IndexProjectTask task = new IndexProjectTask(projectName);
-      if (queuedTasks.add(task)) {
-        executor.execute(task);
-      }
+      forwarder.indexProject(projectName, new IndexEvent());
     }
   }
 
   @Override
   public void onGroupIndexed(String groupUUID) {
     if (!Context.isForwardedEvent()) {
-      IndexGroupTask task = new IndexGroupTask(groupUUID);
-      if (queuedTasks.add(task)) {
-        executor.execute(task);
-      }
-    }
-  }
-
-  abstract class IndexTask implements Runnable {
-    protected final IndexEvent indexEvent;
-    private int retryCount = 0;
-
-    IndexTask() {
-      indexEvent = new IndexEvent();
-    }
-
-    IndexTask(IndexEvent indexEvent) {
-      this.indexEvent = indexEvent;
-    }
-
-    @Override
-    public void run() {
-      locks.withLock(
-          this,
-          () -> {
-            queuedTasks.remove(this);
-            return execute();
-          },
-          this::reschedule);
-    }
-
-    private void reschedule() {
-      if (++retryCount <= maxTries) {
-        log.atFine().log("Retrying %d times to %s", retryCount, this);
-        executor.schedule(this, retryInterval, TimeUnit.MILLISECONDS);
-      } else {
-        log.atSevere().log("Failed to %s after %d tries; giving up", this, maxTries);
-      }
-    }
-
-    abstract CompletableFuture<Boolean> execute();
-
-    abstract String indexId();
-  }
-
-  class IndexChangeTask extends IndexTask {
-    private final int changeId;
-    private final String projectName;
-
-    IndexChangeTask(String projectName, int changeId, IndexEvent indexEvent) {
-      super(indexEvent);
-      this.projectName = projectName;
-      this.changeId = changeId;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.indexChange(projectName, changeId, indexEvent);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(IndexChangeTask.class, changeId);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof IndexChangeTask)) {
-        return false;
-      }
-      IndexChangeTask other = (IndexChangeTask) obj;
-      return changeId == other.changeId;
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Index change %s in target instance", pluginName, changeId);
-    }
-
-    @Override
-    String indexId() {
-      return "change/" + changeId;
-    }
-  }
-
-  class BatchIndexChangeTask extends IndexTask {
-    private final int changeId;
-    private final String projectName;
-
-    BatchIndexChangeTask(String projectName, int changeId, IndexEvent indexEvent) {
-      super(indexEvent);
-      this.projectName = projectName;
-      this.changeId = changeId;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.batchIndexChange(projectName, changeId, indexEvent);
-    }
-
-    @Override
-    String indexId() {
-      return "change/" + changeId;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(IndexEventHandler.BatchIndexChangeTask.class, changeId);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof IndexEventHandler.BatchIndexChangeTask)) {
-        return false;
-      }
-      IndexEventHandler.BatchIndexChangeTask other = (IndexEventHandler.BatchIndexChangeTask) obj;
-      return changeId == other.changeId;
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Index change %s in target instance", pluginName, changeId);
-    }
-  }
-
-  class DeleteChangeTask extends IndexTask {
-    private final int changeId;
-
-    DeleteChangeTask(int changeId, IndexEvent indexEvent) {
-      super(indexEvent);
-      this.changeId = changeId;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.deleteChangeFromIndex(changeId, indexEvent);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(DeleteChangeTask.class, changeId);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof DeleteChangeTask)) {
-        return false;
-      }
-      DeleteChangeTask other = (DeleteChangeTask) obj;
-      return changeId == other.changeId;
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Delete change %s in target instance", pluginName, changeId);
-    }
-
-    @Override
-    String indexId() {
-      return "change/" + changeId;
-    }
-  }
-
-  class IndexAccountTask extends IndexTask {
-    private final int accountId;
-
-    IndexAccountTask(int accountId) {
-      this.accountId = accountId;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.indexAccount(accountId, indexEvent);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(accountId);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof IndexAccountTask)) {
-        return false;
-      }
-      IndexAccountTask other = (IndexAccountTask) obj;
-      return accountId == other.accountId;
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Index account %s in target instance", pluginName, accountId);
-    }
-
-    @Override
-    String indexId() {
-      return "account/" + accountId;
-    }
-  }
-
-  class IndexGroupTask extends IndexTask {
-    private final String groupUUID;
-
-    IndexGroupTask(String groupUUID) {
-      this.groupUUID = groupUUID;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.indexGroup(groupUUID, indexEvent);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(IndexGroupTask.class, groupUUID);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof IndexGroupTask)) {
-        return false;
-      }
-      IndexGroupTask other = (IndexGroupTask) obj;
-      return groupUUID.equals(other.groupUUID);
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Index group %s in target instance", pluginName, groupUUID);
-    }
-
-    @Override
-    String indexId() {
-      return "group/" + groupUUID;
-    }
-  }
-
-  class IndexProjectTask extends IndexTask {
-    private final String projectName;
-
-    IndexProjectTask(String projectName) {
-      this.projectName = projectName;
-    }
-
-    @Override
-    public CompletableFuture<Boolean> execute() {
-      return forwarder.indexProject(projectName, indexEvent);
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(IndexProjectTask.class, projectName);
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof IndexProjectTask)) {
-        return false;
-      }
-      IndexProjectTask other = (IndexProjectTask) obj;
-      return projectName.equals(other.projectName);
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[%s] Index project %s in target instance", pluginName, projectName);
-    }
-
-    @Override
-    String indexId() {
-      return "project/" + projectName;
+      forwarder.indexGroup(groupUUID, new IndexEvent());
     }
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventLocks.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventLocks.java
deleted file mode 100644
index bbff4ee..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventLocks.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2020 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.index;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.IndexTask;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Striped;
-import com.google.inject.Inject;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.Semaphore;
-import java.util.concurrent.TimeUnit;
-
-public class IndexEventLocks {
-  private static final FluentLogger log = FluentLogger.forEnclosingClass();
-
-  private static final int NUMBER_OF_INDEX_TASK_TYPES = 4;
-  private static final int WAIT_TIMEOUT_MS = 5;
-
-  private final Striped<Semaphore> semaphores;
-
-  @Inject
-  public IndexEventLocks(Configuration cfg) {
-    this.semaphores =
-        Striped.semaphore(NUMBER_OF_INDEX_TASK_TYPES * cfg.index().numStripedLocks(), 1);
-  }
-
-  public CompletableFuture<?> withLock(
-      IndexTask id, IndexCallFunction function, VoidFunction lockAcquireTimeoutCallback) {
-    String indexId = id.indexId();
-    Semaphore idSemaphore = getSemaphore(indexId);
-    try {
-      log.atFine().log("Trying to acquire %s", id);
-      if (idSemaphore.tryAcquire(WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
-        log.atFine().log("Acquired %s", id);
-        return function
-            .invoke()
-            .whenComplete(
-                (result, error) -> {
-                  try {
-                    log.atFine().log("Trying to release %s", id);
-                    idSemaphore.release();
-                    log.atFine().log("Released %s", id);
-                  } catch (Throwable t) {
-                    log.atSevere().withCause(t).log("Unable to release %s", id);
-                    throw t;
-                  }
-                });
-      }
-
-      String timeoutMessage =
-          String.format(
-              "Acquisition of the locking of %s timed out after %d msec: consider increasing the number of shards",
-              indexId, WAIT_TIMEOUT_MS);
-      log.atWarning().log("%s", timeoutMessage);
-      lockAcquireTimeoutCallback.invoke();
-      CompletableFuture<?> failureFuture = new CompletableFuture<>();
-      failureFuture.completeExceptionally(new InterruptedException(timeoutMessage));
-      return failureFuture;
-    } catch (InterruptedException e) {
-      CompletableFuture<?> failureFuture = new CompletableFuture<>();
-      failureFuture.completeExceptionally(e);
-      log.atSevere().withCause(e).log("Locking of %s was interrupted; giving up", indexId);
-      return failureFuture;
-    }
-  }
-
-  @VisibleForTesting
-  protected Semaphore getSemaphore(String indexId) {
-    return semaphores.get(indexId);
-  }
-
-  @FunctionalInterface
-  public interface VoidFunction {
-    void invoke();
-  }
-
-  @FunctionalInterface
-  public interface IndexCallFunction {
-    CompletableFuture<?> invoke();
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutor.java
deleted file mode 100644
index 289b588..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutor.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 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.index;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface IndexExecutor {}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProvider.java
deleted file mode 100644
index 1d02f2c..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProvider.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2015 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.index;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-class IndexExecutorProvider extends ExecutorProvider {
-
-  @Inject
-  IndexExecutorProvider(WorkQueue workQueue, Configuration config) {
-    super(workQueue, config.index().threadPoolSize(), "Forward-Index-Event");
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexModule.java
index 3bcc34f..1db3721 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexModule.java
@@ -14,34 +14,40 @@
 
 package com.ericsson.gerrit.plugins.highavailability.index;
 
+import com.ericsson.gerrit.plugins.highavailability.ExecutorProvider;
 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.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.inject.AbstractModule;
 import com.google.inject.Scopes;
+import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
-import java.util.concurrent.ScheduledExecutorService;
+import dev.failsafe.FailsafeExecutor;
 
-public class IndexModule extends LifecycleModule {
+public class IndexModule extends AbstractModule {
 
   @Override
   protected void configure() {
-    bind(ScheduledExecutorService.class)
-        .annotatedWith(IndexExecutor.class)
-        .toProvider(IndexExecutorProvider.class);
-    bind(ScheduledExecutorService.class)
+    bind(new TypeLiteral<FailsafeExecutor<Boolean>>() {})
         .annotatedWith(ForwardedIndexExecutor.class)
-        .toProvider(ForwardedIndexExecutorProvider.class);
-    bind(IndexEventLocks.class).in(Scopes.SINGLETON);
-    bind(ScheduledExecutorService.class)
-        .annotatedWith(BatchIndexExecutor.class)
-        .toProvider(BatchIndexExecutorProvider.class);
-    bind(ScheduledExecutorService.class)
+        .toProvider(ForwardedIndexFailsafeExecutorProvider.class)
+        .in(Scopes.SINGLETON);
+    bind(ExecutorProvider.class)
+        .annotatedWith(ForwardedIndexExecutor.class)
+        .to(ForwardedIndexExecutorProvider.class)
+        .in(Scopes.SINGLETON);
+
+    bind(new TypeLiteral<FailsafeExecutor<Boolean>>() {})
         .annotatedWith(ForwardedBatchIndexExecutor.class)
-        .toProvider(ForwardedBatchIndexExecutorProvider.class);
-    listener().to(IndexExecutorProvider.class);
+        .toProvider(ForwardedBatchIndexFailsafeExecutorProvider.class)
+        .in(Scopes.SINGLETON);
+    bind(ExecutorProvider.class)
+        .annotatedWith(ForwardedBatchIndexExecutor.class)
+        .to(ForwardedBatchIndexExecutorProvider.class)
+        .in(Scopes.SINGLETON);
+
     DynamicSet.bind(binder(), ChangeIndexedListener.class)
         .to(IndexEventHandler.class)
         .in(Scopes.SINGLETON);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
index 68707c2..0a7442c 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfoModule.java
@@ -15,6 +15,7 @@
 package com.ericsson.gerrit.plugins.highavailability.peers;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.JChannelProviderModule;
 import com.ericsson.gerrit.plugins.highavailability.peers.jgroups.JGroupsPeerInfoProvider;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.TypeLiteral;
@@ -37,6 +38,7 @@
       case JGROUPS:
         bind(new TypeLiteral<Set<PeerInfo>>() {}).toProvider(JGroupsPeerInfoProvider.class);
         listener().to(JGroupsPeerInfoProvider.class);
+        install(new JChannelProviderModule());
         break;
       default:
         throw new IllegalArgumentException("Unsupported peer info strategy: " + strategy);
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java
index 4b86646..77a81b3 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/InetAddressFinder.java
@@ -16,6 +16,7 @@
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.net.Inet4Address;
@@ -29,7 +30,8 @@
 import java.util.Optional;
 
 @Singleton
-class InetAddressFinder {
+public class InetAddressFinder {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
   private final boolean preferIPv4;
   private final Configuration.JGroups jgroupsConfig;
@@ -48,7 +50,7 @@
    *
    * @return an Optional<InetAddress>
    */
-  Optional<InetAddress> findAddress() throws SocketException {
+  public Optional<InetAddress> findAddress() throws SocketException {
     return findFirstAppropriateAddress(Collections.list(NetworkInterface.getNetworkInterfaces()));
   }
 
@@ -56,16 +58,28 @@
   Optional<InetAddress> findFirstAppropriateAddress(List<NetworkInterface> networkInterfaces)
       throws SocketException {
     for (NetworkInterface ni : networkInterfaces) {
-      if (ni.isLoopback() || !ni.isUp() || !ni.supportsMulticast() || shouldSkip(ni.getName())) {
+      if (ni.isLoopback() || !ni.isUp() || !ni.supportsMulticast()) {
+        log.atFine().log(
+            "ignoring network interface %s [isLoopback: %s, isUp: %s, supportsMulticast: %s]",
+            ni.getName(), ni.isLoopback(), ni.isUp(), ni.supportsMulticast());
+        continue;
+      }
+      if (shouldSkip(ni.getName())) {
         continue;
       }
       Enumeration<InetAddress> inetAddresses = ni.getInetAddresses();
       while (inetAddresses.hasMoreElements()) {
         InetAddress a = inetAddresses.nextElement();
         if (preferIPv4 && a instanceof Inet4Address) {
+          log.atFine().log(
+              "using IPv4 network interface %s [hostAddress = %s, hostName: %s]",
+              ni.getName(), a.getHostAddress(), a.getHostName());
           return Optional.of(a);
         }
         if (!preferIPv4 && a instanceof Inet6Address) {
+          log.atFine().log(
+              "using IPv6 network interface %s [hostAddress: %s, hostName: %s]",
+              ni.getName(), a.getHostAddress(), a.getHostName());
           return Optional.of(a);
         }
       }
@@ -76,10 +90,8 @@
   @VisibleForTesting
   boolean shouldSkip(String name) {
     for (String s : jgroupsConfig.skipInterface()) {
-      if (s.endsWith("*") && name.startsWith(s.substring(0, s.length() - 1))) {
-        return true;
-      }
-      if (name.equals(s)) {
+      if (name.equals(s) || s.endsWith("*") && name.startsWith(s.substring(0, s.length() - 1))) {
+        log.atFine().log("skipping network interface %s because it matches %s", name, s);
         return true;
       }
     }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProvider.java
new file mode 100644
index 0000000..f39e641
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProvider.java
@@ -0,0 +1,63 @@
+// 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.peers.jgroups;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.nio.file.Path;
+import java.util.Optional;
+import org.jgroups.JChannel;
+
+@Singleton
+public class JChannelProvider implements Provider<JChannel> {
+  private final Configuration.JGroups jgroupsConfig;
+  private final Configuration.JGroupsKubernetes jgroupsKubernetesConfig;
+
+  @Inject
+  JChannelProvider(Configuration pluginConfiguration) {
+    this.jgroupsConfig = pluginConfiguration.jgroups();
+    this.jgroupsKubernetesConfig = pluginConfiguration.jgroupsKubernetes();
+  }
+
+  @Override
+  public JChannel get() {
+    Optional<Path> protocolStack = jgroupsConfig.protocolStack();
+    try {
+      if (protocolStack.isPresent()) {
+        return new JChannel(protocolStack.get().toString());
+      }
+      if (jgroupsConfig.useKubernetes()) {
+        if (jgroupsKubernetesConfig.namespace() != null) {
+          System.setProperty("KUBERNETES_NAMESPACE", jgroupsKubernetesConfig.namespace());
+        }
+        if (!jgroupsKubernetesConfig.labels().isEmpty()) {
+          System.setProperty(
+              "KUBERNETES_LABELS", String.join(",", jgroupsKubernetesConfig.labels()));
+        }
+        return new JChannel(getClass().getResource("kubernetes.xml").toString());
+      }
+      return new JChannel();
+    } catch (Exception e) {
+      throw new ProvisionException(
+          String.format(
+              "Unable to create a channel with protocol stack: %s",
+              protocolStack.map(Path::toString).orElse("default")),
+          e);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProviderModule.java
similarity index 60%
copy from src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
copy to src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProviderModule.java
index 9b33ca3..c4764c8 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutor.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JChannelProviderModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2023 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.
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.ericsson.gerrit.plugins.highavailability.cache;
+package com.ericsson.gerrit.plugins.highavailability.peers.jgroups;
 
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import com.google.inject.AbstractModule;
+import org.jgroups.JChannel;
 
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-@Retention(RUNTIME)
-@BindingAnnotation
-@interface CacheExecutor {}
+public class JChannelProviderModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(JChannel.class).toProvider(JChannelProvider.class);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java
index 68efbec..776a0a4 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProvider.java
@@ -24,7 +24,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.net.InetAddress;
-import java.nio.file.Path;
 import java.util.Optional;
 import java.util.Set;
 import org.jgroups.Address;
@@ -48,16 +47,8 @@
 public class JGroupsPeerInfoProvider
     implements Receiver, Provider<Set<PeerInfo>>, LifecycleListener {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
-  private static final String JGROUPS_LOG_FACTORY_PROPERTY = "jgroups.logging.log_factory_class";
-
-  static {
-    if (System.getProperty(JGROUPS_LOG_FACTORY_PROPERTY) == null) {
-      System.setProperty(JGROUPS_LOG_FACTORY_PROPERTY, SLF4JLogFactory.class.getName());
-    }
-  }
 
   private final Configuration.JGroups jgroupsConfig;
-  private final Configuration.JGroupsKubernetes jgroupsKubernetesConfig;
   private final InetAddressFinder finder;
   private final String myUrl;
 
@@ -67,11 +58,14 @@
 
   @Inject
   JGroupsPeerInfoProvider(
-      Configuration pluginConfiguration, InetAddressFinder finder, MyUrlProvider myUrlProvider) {
+      Configuration pluginConfiguration,
+      InetAddressFinder finder,
+      MyUrlProvider myUrlProvider,
+      JChannel channel) {
     this.jgroupsConfig = pluginConfiguration.jgroups();
-    this.jgroupsKubernetesConfig = pluginConfiguration.jgroupsKubernetes();
     this.finder = finder;
     this.myUrl = myUrlProvider.get();
+    this.channel = channel;
   }
 
   @Override
@@ -120,7 +114,6 @@
 
   public void connect() {
     try {
-      channel = getChannel();
       Optional<InetAddress> address = finder.findAddress();
       if (address.isPresent()) {
         log.atFine().log("Protocol stack: %s", channel.getProtocolStack());
@@ -146,31 +139,6 @@
     }
   }
 
-  private JChannel getChannel() throws Exception {
-    Optional<Path> protocolStack = jgroupsConfig.protocolStack();
-    try {
-      if (protocolStack.isPresent()) {
-        return new JChannel(protocolStack.get().toString());
-      }
-      if (jgroupsConfig.useKubernetes()) {
-        if (jgroupsKubernetesConfig.namespace() != null) {
-          System.setProperty("KUBERNETES_NAMESPACE", jgroupsKubernetesConfig.namespace());
-        }
-        if (!jgroupsKubernetesConfig.labels().isEmpty()) {
-          System.setProperty(
-              "KUBERNETES_LABELS", String.join(",", jgroupsKubernetesConfig.labels()));
-        }
-        return new JChannel(getClass().getResource("kubernetes.xml").toString());
-      }
-      return new JChannel();
-    } catch (Exception e) {
-      log.atSevere().withCause(e).log(
-          "Unable to create a channel with protocol stack: %s",
-          protocolStack.isPresent() ? protocolStack : "default");
-      throw e;
-    }
-  }
-
   @VisibleForTesting
   void setChannel(JChannel channel) {
     this.channel = channel;
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLog.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLog.java
deleted file mode 100644
index 43b6f9c..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLog.java
+++ /dev/null
@@ -1,176 +0,0 @@
-// Copyright (C) 2017 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.peers.jgroups;
-
-import org.jgroups.logging.Log;
-import org.slf4j.Logger;
-
-/**
- * A {@link Log} implementation which forwards all calls to the JGroups specific logging interface
- * to an instance of a SLF4J {@link Logger}. Only {@link #setLevel(String)} and {@link #getLevel()}
- * have no counterparts in SLF4J and are implemented as NOPs. Instances of this class are created by
- * {@link SLF4JLogFactory}. See the documentation of {@link SLF4JLogFactory} on how to configure
- * JGroups to use this class.
- *
- * @author christian.halstrick@sap.com
- */
-public class SLF4JLog implements Log {
-  private final Logger logger;
-
-  public SLF4JLog(Logger log) {
-    this.logger = log;
-  }
-
-  @Override
-  public boolean isFatalEnabled() {
-    return logger.isErrorEnabled();
-  }
-
-  @Override
-  public boolean isErrorEnabled() {
-    return logger.isErrorEnabled();
-  }
-
-  @Override
-  public boolean isWarnEnabled() {
-    return logger.isWarnEnabled();
-  }
-
-  @Override
-  public boolean isInfoEnabled() {
-    return logger.isInfoEnabled();
-  }
-
-  @Override
-  public boolean isDebugEnabled() {
-    return logger.isDebugEnabled();
-  }
-
-  @Override
-  public boolean isTraceEnabled() {
-    return logger.isTraceEnabled();
-  }
-
-  @Override
-  public void fatal(String msg) {
-    logger.error(msg);
-  }
-
-  @Override
-  public void fatal(String msg, Object... args) {
-    logger.error(convMsg(msg, args));
-  }
-
-  @Override
-  public void fatal(String msg, Throwable throwable) {
-    logger.error(msg, throwable);
-  }
-
-  @Override
-  public void error(String msg) {
-    logger.error(msg);
-  }
-
-  @Override
-  public void error(String format, Object... args) {
-    if (isErrorEnabled()) {
-      logger.error(convMsg(format, args));
-    }
-  }
-
-  @Override
-  public void error(String msg, Throwable throwable) {
-    logger.error(msg, throwable);
-  }
-
-  @Override
-  public void warn(String msg) {
-    logger.warn(msg);
-  }
-
-  @Override
-  public void warn(String msg, Object... args) {
-    if (isWarnEnabled()) {
-      logger.warn(convMsg(msg, args));
-    }
-  }
-
-  @Override
-  public void warn(String msg, Throwable throwable) {
-    logger.warn(msg, throwable);
-  }
-
-  @Override
-  public void info(String msg) {
-    logger.info(msg);
-  }
-
-  @Override
-  public void info(String msg, Object... args) {
-    if (isInfoEnabled()) {
-      logger.info(convMsg(msg, args));
-    }
-  }
-
-  @Override
-  public void debug(String msg) {
-    logger.debug(msg);
-  }
-
-  @Override
-  public void debug(String msg, Object... args) {
-    if (isDebugEnabled()) {
-      logger.debug(convMsg(msg, args));
-    }
-  }
-
-  @Override
-  public void debug(String msg, Throwable throwable) {
-    logger.debug(msg, throwable);
-  }
-
-  @Override
-  public void trace(Object msg) {
-    logger.trace(msg.toString());
-  }
-
-  @Override
-  public void trace(String msg) {
-    logger.trace(msg);
-  }
-
-  @Override
-  public void trace(String msg, Object... args) {
-    if (isTraceEnabled()) {
-      logger.trace(convMsg(msg, args));
-    }
-  }
-
-  @Override
-  public void trace(String msg, Throwable throwable) {
-    logger.trace(msg, throwable);
-  }
-
-  private static String convMsg(String msg, Object... args) {
-    return String.format(msg, args);
-  }
-
-  @Override
-  public void setLevel(String level) {}
-
-  @Override
-  public String getLevel() {
-    return "";
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLogFactory.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLogFactory.java
deleted file mode 100644
index 72ea803..0000000
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/SLF4JLogFactory.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2017 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.peers.jgroups;
-
-import org.jgroups.logging.CustomLogFactory;
-import org.jgroups.logging.Log;
-import org.slf4j.LoggerFactory;
-
-/**
- * A custom log factory which takes care to provide instances of {@link SLF4JLog}. If this factory
- * is used jgroups will use the SLF4J API to emit logging and tracing. This can be helpful when
- * gerrit is running in a runtime environment which supports SLF4J logging.
- *
- * <p>To configure jgroups to use this log factory make sure the following system property is set
- * when gerrit is started:
- *
- * <p>{@code
- * -Djgroups.logging.log_factory_class=com.ericsson.gerrit.plugins.highavailability.peers.jgroups.SLF4JLogFactory}
- */
-public class SLF4JLogFactory implements CustomLogFactory {
-
-  @Override
-  public Log getLog(@SuppressWarnings("rawtypes") Class clazz) {
-    return new SLF4JLog(LoggerFactory.getLogger(clazz));
-  }
-
-  @Override
-  public Log getLog(String category) {
-    return new SLF4JLog(LoggerFactory.getLogger(category));
-  }
-}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java
index afc6568..b4260a7 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java
@@ -25,6 +25,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.time.Duration;
 import java.util.concurrent.ScheduledFuture;
 
 @Singleton
@@ -32,7 +33,7 @@
 
   private final WorkQueue queue;
   private final Provider<CleanupTask> cleanupTaskProvider;
-  private final long cleanupIntervalMillis;
+  private final Duration cleanupInterval;
   private ScheduledFuture<?> scheduledCleanupTask;
 
   static class CleanupTask implements Runnable {
@@ -64,7 +65,7 @@
       WorkQueue queue, Provider<CleanupTask> cleanupTaskProvider, Configuration config) {
     this.queue = queue;
     this.cleanupTaskProvider = cleanupTaskProvider;
-    this.cleanupIntervalMillis = config.websession().cleanupInterval();
+    this.cleanupInterval = config.websession().cleanupInterval();
   }
 
   @Override
@@ -75,7 +76,7 @@
             .scheduleAtFixedRate(
                 cleanupTaskProvider.get(),
                 SECONDS.toMillis(1),
-                cleanupIntervalMillis,
+                cleanupInterval.toMillis(),
                 MILLISECONDS);
   }
 
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 2d9c10c..d89f7b3 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -54,6 +54,25 @@
   enable = true
 ```
 
+### JGroups based discovery and message transport
+
+In this case URLs of peers are not needed. All peers just need to join the same JGroups cluster
+defined by the `jgroups.clusterName`.
+```
+[main]
+  transport = jgroups
+  sharedDirectory = /directory/accessible/from/both/instances
+[autoReindex]
+  enabled = false
+[jgroups]
+  clusterName = foo
+  skipInterface = lo*
+  skipInterface = eth2
+  protocolStack = protocolStack.xml
+  timeout = 5000
+  maxTries = 100
+```
+
 ```main.sharedDirectory```
 :   Path to a directory accessible from both instances.
     When given as a relative path, then it is resolved against the $SITE_PATH
@@ -62,6 +81,11 @@
     directory is "/gerrit/root/shared/dir". When not specified, the default
     is "shared".
 
+```main.transport```
+:   Message transport layer. Could be: `http` or `jgroups`.
+    When not specificed the default is `http`.
+    When set to `jgroups` then all `peerInfo.*` sections are unnecessary and ignored.
+
 ```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
@@ -147,6 +171,27 @@
 :   A label that will be used to select the pods in the format `label=value`. Can be set
     multiple times.
 
+```jgroups.timeout```
+:   Maximum interval of time in milliseconds the JGroups wait for a response
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    forwarding a message. When not specified, the default value is 5 seconds.
+
+```jgroups.maxTries```
+:   Maximum number of times JGroups should attempt to forward a messages. Must be at least 1.
+    Setting this option to zero or negative will assume the default value.
+    When not specified, the default value is 720 times.
+
+```jgroups.retryInterval```
+:   The interval of time in milliseconds between the subsequent auto-retries.
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default value is set to 10 seconds.
+
+```jgroups.threadPoolSize```
+:   Maximum number of threads used to execute JGroups calls towards target instances.
+
+NOTE: the default settings for `jgroups.timeout` and `jgroups.maxTries` ensure
+that JGroups will keep retrying to forward a message for one hour.
+
 NOTE: To work properly in certain environments, JGroups needs the System property
 `java.net.preferIPv4Stack` to be set to `true`.
 See [JGroups - Trouble shooting](http://jgroups.org/tutorial/index.html#_trouble_shooting).
@@ -163,31 +208,35 @@
 
 ```http.connectionTimeout```
 :   Maximum interval of time in milliseconds the plugin waits for a connection
-    to the target instance. When not specified, the default value is set to 5000ms.
+    to the target instance.
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default value is set to 5 seconds.
 
 ```http.socketTimeout```
 :   Maximum interval of time in milliseconds the plugin waits for a response from the
-    target instance once the connection has been established. When not specified,
-    the default value is set to 5000ms.
+    target instance once the connection has been established.
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default value is set to 5 seconds.
 
 ```http.maxTries```
 :   Maximum number of times the plugin should attempt when calling a REST API in
-    the target instance. Setting this value to 0 will disable retries. When not
-    specified, the default value is 360. After this number of failed tries, an
+    the target instance. Must be at least 1.
+    Setting this option to zero or negative will assume the default value;
+    When not specified, the default value is 360. After this number of failed attempts, an
     error is logged.
 
 ```http.retryInterval```
 :   The interval of time in milliseconds between the subsequent auto-retries.
-    When not specified, the default value is set to 10000ms.
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default value is set to 10 seconds.
+
+```http.threadPoolSize```
+:   Maximum number of threads used to execute REST calls towards target instances.
 
 ```cache.synchronize```
 :   Whether to synchronize cache evictions.
     Defaults to true.
 
-```cache.threadPoolSize```
-:   Maximum number of threads used to send cache evictions to the target instance.
-    Defaults to 4.
-
 ```cache.pattern```
 :   Pattern to match names of custom caches for which evictions should be
     forwarded (in addition to the core caches that are always forwarded). May be
@@ -205,14 +254,6 @@
 :   Whether to synchronize stream events.
     Defaults to true.
 
-```index.numStripedLocks```
-:   Number of striped locks to use during reindexing. Should be of the same order
-    of magnitude of the open changes. I.e.: if one has 5000 changes, one might want
-    to have at least 1000 striped locks. The value has to be tuned empirically
-    by checking the number of failures in acquiring the locking. Checking the
-    `consider increasing the number of shards` warnings should help.
-    Defaults to 10.
-
 ```index.synchronize```
 :   Whether to synchronize secondary indexes.
     Defaults to true.
@@ -223,24 +264,26 @@
     Defaults to true.
 
 ```index.threadPoolSize```
-:   Maximum number of threads used to send index events to the target instance.
+:   Maximum number of threads used to process index events in the receiving gerrit instance.
     Defaults to 4.
 
 ```index.batchThreadPoolSize```
-:   Maximum number of threads used to send batch index events to the target instance
+:   Maximum number of threads used to process batch index events in the receiving gerrit instance
     and not associated to an interactive action performed by a user.
-    Defaults equal index.threadPoolSize.
+    Defaults to `index.threadPoolSize`.
 
 ```index.maxTries```
 :   Maximum number of times the plugin should attempt to reindex changes.
-    Setting this value to 0 will disable retries. After this number of failed tries,
-    an error is logged and the local index should be considered stale and needs
-    to be investigated and manually reindexed.
+    Must be at least 1.
+    Setting this option to zero or negative will assume the default value;
+    After this number of failed tries, an error is logged and the local index should be considered
+    stale and needs to be investigated and manually reindexed.
     Defaults to 2.
 
 ```index.retryInterval```
 :   The interval of time in milliseconds between the subsequent auto-retries.
-    Defaults to 30000 (30 seconds).
+    Value is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    Defaults to 30 seconds.
 
 NOTE: the default settings for `http.socketTimeout` and `http.maxTries` ensure
 that the plugin will keep retrying to forward a message for one hour.
diff --git a/src/test/README.md b/src/test/README.md
index 1b8e4a2..fd59f64 100644
--- a/src/test/README.md
+++ b/src/test/README.md
@@ -1,19 +1,31 @@
 # Gerrit high-availability docker setup example
 
 The Docker Compose project in the docker directory contains a simple test 
-environment of two Gerrit masters in HA configuration.
+environment of two Gerrit masters in HA configuration, with their git repos
+hosted on NFS filesystem.
 
 ## How to build
 
-The project can be built using docker-compose.
+The project can be built using docker-compose (make sure you set the
+`platform` attribute in the docker-compose.yaml file if you're not 
+in an amd64 arch).
 
 To build the Docker VMs:
-```
-  $ docker-compose build
+```bash
+  # first, remove the buildx if it exists and its not running
+  $ docker buildx inspect docker-ha | grep Status
+  $ docker buildx rm docker-ha
+  # create the docker-ha buildx node, provide your architecture and start it up
+  docker buildx create --name docker-ha --platform "linux/amd64" --driver docker-container --use \
+  && docker buildx inspect --bootstrap \
+  && docker-compose build
 ```
 
 ### Building the Docker VMs using a non-default user id
 
+First, update the user id in the [NFS Dockerfile](./docker/nfs/Dockerfile).
+This is done simply by modifying the file setting the non-default user id.
+Then, run the following:
 ```
   $ export GERRIT_UID=$(id -u)
   $ docker-compose build --build-arg GERRIT_UID
@@ -24,6 +36,9 @@
 host is not owned by you. For example, some corporate environments use a
 restricted 1000 user (id). In that case, the containerized application
 may fail to write towards the host (through volumes).
+**Important:** The user id in gerrit must be the same as the uid in the
+NFS server, otherwise you will encounter file ownership problems on any
+filesystem operation.
 
 That UID will be the one set for the containerized gerrit user. Latter's
 group will remain as default (1000). This is because groups known from
@@ -36,9 +51,41 @@
 Use the 'up' target to startup the Docker Compose VMs.
 
 ```
-  $ docker-compose up
+  $ docker-compose up -d
 ```
 
+## Background on using an NFS server
+We are using the `erichough/nfs-server` image mainly because it's easy to use
+& we had success with it. The work has been inspired by
+[this blog post](https://nothing2say.co.uk/running-a-linux-based-nfs-server-in-docker-on-windows-b64445d5ada2).
+
+The containers start with the `privileged` flag set, which is a security risk
+but necessary to work around permission issues.
+
+It is worth noting that we are exposing the `/var/gerrit/git` directory as the
+nfs-share. This is because more often than not it's the git directory that's
+shared over the network. You can change this in the nfs server and gerrit
+docker files, and in the `exports.txt` file.
+
+The NFS server is using a static IP. The Docker Compose YAML file defines a 
+bridge network with the subnet `192.168.1.0/24` (this is what allows us to
+give the NFS Server a known, static IP).
+
+The `addr=192.168.1.5` option (in the `nfs-client-volume` volume) is the 
+reason we need a static IP for the server (and hence a configured subnet
+for the network). Note that using a name (ie. addr=nfs-server) we weren't
+able to get the DNS resolution to work properly.
+
+Also in the Docker Compose file we can see that the `nfs-server` container
+uses a `healthcheck`, this is necessary to control when the `gerrit`
+services will start up (they need to start after the nfs server is fully
+up-and-running).
+
+Finally, we are providing an `exports.txt` file, which again utilises the
+subnet we provided during the bridge network creation. This file is baked
+into the image sacrificing a bit of flexibility, but we feel this is
+a small price to pay to have everything automated.
+
 # Gerrit high-availability local setup example
 
  1. Init gerrit instances with high-availability plugin installed:
diff --git a/src/test/docker/docker-compose.yaml b/src/test/docker/docker-compose.yaml
index 24140ca..f744d2e 100644
--- a/src/test/docker/docker-compose.yaml
+++ b/src/test/docker/docker-compose.yaml
@@ -2,16 +2,40 @@
 
 services:
 
+  nfs-server:
+    build: nfs
+#    platform: linux/arm64/v8 # uncomment for Apple Silicon arch
+    privileged: true
+    container_name: nfs-server
+    environment:
+      NFS_LOG_LEVEL: DEBUG
+    hostname: nfs-server
+    healthcheck:
+      test: ["CMD-SHELL", "sleep 10"] # required, otherwise the gerrit service will fail to start with a "connection refused" error
+      interval: 1s
+      timeout: 1m
+      retries: 10
+    ports:
+      - 2049:2049
+    networks:
+      nfs-server-bridge:
+        ipv4_address: 192.168.1.5
+    volumes:
+      - nfs-server-volume:/var/gerrit/git
   gerrit-01:
     build: gerrit
+    privileged: true
+    depends_on:
+      nfs-server:
+        condition: service_healthy
     ports:
       - "8081:8080"
       - "29411:29418"
     networks:
-      - gerrit-net
+      nfs-server-bridge: null
     volumes:
       - /dev/urandom:/dev/random
-      - gitvolume:/var/gerrit/git
+      - git-volume:/var/gerrit/git
       - shareddir:/var/gerrit/shareddir
       - ./etc/gerrit.config:/var/gerrit/etc/gerrit.config.orig
       - ./etc/high-availability.gerrit-01.config:/var/gerrit/etc/high-availability.config.orig
@@ -20,16 +44,20 @@
 
   gerrit-02:
     build: gerrit
+    privileged: true
     ports:
       - "8082:8080"
       - "29412:29418"
     networks:
-      - gerrit-net
+      nfs-server-bridge: null
     depends_on:
-      - gerrit-01
+      gerrit-01:
+        condition: service_started
+      nfs-server:
+        condition: service_healthy
     volumes:
       - /dev/urandom:/dev/random
-      - gitvolume:/var/gerrit/git
+      - git-volume:/var/gerrit/git
       - shareddir:/var/gerrit/shareddir
       - ./etc/gerrit.config:/var/gerrit/etc/gerrit.config.orig
       - ./etc/high-availability.gerrit-02.config:/var/gerrit/etc/high-availability.config.orig
@@ -43,7 +71,9 @@
       - "80:80"
       - "29418:29418"
     networks:
-      - gerrit-net
+      nfs-server-bridge: null
+    volumes:
+      - syslog-sidecar:/syslog-sidecar
     depends_on:
       - syslog-sidecar
       - gerrit-01
@@ -52,12 +82,21 @@
   syslog-sidecar:
     build: docker-syslog-ng-stdout
     networks:
-      - gerrit-net
+      nfs-server-bridge: null
 
 networks:
-  gerrit-net:
-    driver: bridge
+  nfs-server-bridge:
+    ipam:
+      driver: default
+      config:
+        - subnet: 192.168.1.0/24
 
 volumes:
   shareddir:
-  gitvolume:
+  nfs-server-volume:
+  git-volume:
+    driver: "local"
+    driver_opts:
+      type: nfs
+      o: "addr=192.168.1.5,rw"
+      device: ":/var/gerrit/git"
diff --git a/src/test/docker/docker-syslog-ng-stdout/Dockerfile b/src/test/docker/docker-syslog-ng-stdout/Dockerfile
index 11f3059..52c1e64 100644
--- a/src/test/docker/docker-syslog-ng-stdout/Dockerfile
+++ b/src/test/docker/docker-syslog-ng-stdout/Dockerfile
@@ -1,10 +1,10 @@
-FROM alpine:3.4
+FROM alpine:3.13.4
 MAINTAINER Ryan Schlesinger <ryan@outstand.com>
 
 RUN apk add --no-cache bash syslog-ng
 
 RUN mkdir /sidecar
-COPY config/* /etc/syslog-ng/
+COPY config/syslog-ng.conf /etc/syslog-ng/
 COPY docker-entrypoint.sh /docker-entrypoint.sh
 VOLUME ["/sidecar"]
 CMD ["syslog-ng", "-F"]
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-destination.out b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-destination.out
deleted file mode 100644
index 170f7e4..0000000
--- a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-destination.out
+++ /dev/null
@@ -1 +0,0 @@
-  destination d_stdout { pipe("/dev/stdout"); };
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-log.out b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-log.out
deleted file mode 100644
index 6901c41..0000000
--- a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-log.out
+++ /dev/null
@@ -1 +0,0 @@
-log { source(s_all); destination(d_stdout); };
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-plugins.std b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-plugins.std
deleted file mode 100644
index ef0ce3e..0000000
--- a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-plugins.std
+++ /dev/null
@@ -1 +0,0 @@
-@version: 3.7
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.sidecar b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.sidecar
deleted file mode 100644
index 1181f78..0000000
--- a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.sidecar
+++ /dev/null
@@ -1,5 +0,0 @@
-# sidecar log source for mounting between docker containers
-  network(
-    transport("udp")
-    port("514")
-  );
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.std b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.std
deleted file mode 100644
index 5a50916..0000000
--- a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng-source.std
+++ /dev/null
@@ -1,8 +0,0 @@
-# ---------------------------------------------------------------------------------
-# Default syslog-ng sources; Do not edit this file!
-# append source with line on a file: syslog-ng-source.<package>
-# ---------------------------------------------------------------------------------
-# message generated by Syslog-NG
-  internal();
-# standard Linux log source (this is the default place for the syslog() function to send logs to)
-  unix-dgram("/dev/log");
diff --git a/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng.conf b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng.conf
new file mode 100644
index 0000000..94cdac9
--- /dev/null
+++ b/src/test/docker/docker-syslog-ng-stdout/config/syslog-ng.conf
@@ -0,0 +1,136 @@
+@version:3.30
+@include "scl.conf"
+
+# syslog-ng configuration file.
+#
+# See syslog-ng(8) and syslog-ng.conf(5) for more information.
+#
+# Note: It also sources additional configuration files (*.conf)
+#	   located in /etc/syslog-ng/conf.d/.
+
+#
+# Options
+#
+options {
+	# Create destination directories if missing.
+	create_dirs(yes);
+
+	# The default action of syslog-ng is to log a MARK line to the file every
+	# 20 minutes. That's seems high for most people so turn it down to once an
+	# hour. Set it to zero if you don't want the functionality at all.
+	mark_freq(3600);
+
+	# The default action of syslog-ng is to log a STATS line to the file every
+	# 10 minutes. That's pretty ugly after a while. Change it to every 12 hours
+	# so you get a nice daily update of how many messages syslog-ng missed (0).
+	stats_freq(43200);
+
+	# Time to wait before a died connection is re-established (default is 60).
+	time_reopen(5);
+
+	# Disable DNS usage.
+	# syslog-ng blocks on DNS queries, so enabling DNS may lead to a DoS attack.
+	use_dns(no);
+	dns-cache(no);
+
+	# Default owner, group, and permissions for log files.
+	owner(root);
+	group(adm);
+	perm(0640);
+
+	# Default permissions for created directories.
+	dir_perm(0755);
+};
+
+
+#
+# Templates
+#
+
+template t_file {
+	template("${YEAR}-${MONTH}-${DAY} ${HOUR}:${MIN}:${SEC} ${LEVEL} ${MSGHDR}${MSG}\n");
+};
+
+
+#
+# Sources
+#
+
+#source s_sys {
+	# Standard system log source.
+#	system();
+
+	# Messages generated by syslog-ng.
+#	internal();
+#};
+
+# customisation
+source s_all {
+  internal();
+  unix-dgram("/dev/log");
+  network(
+    transport("udp")
+    port(514)
+  );
+};
+
+
+#
+# Destinations
+#
+
+destination d_auth { file("/var/log/auth.log" template(t_file)); };
+destination d_boot { file("/var/log/boot.log" template(t_file)); };
+destination d_cron { file("/var/log/cron.log" template(t_file)); };
+destination d_kern { file("/var/log/kern.log" template(t_file)); };
+destination d_mail { file("/var/log/mail.log" template(t_file) flush_lines(10)); };
+destination d_mesg { file("/var/log/messages" template(t_file)); };
+
+# Send messages to console of everyone logged in.
+destination d_cons_all { usertty("*"); };
+
+# Send message to the root's console.
+destination d_cons_root { usertty("root"); };
+
+# customisation
+destination d_stdout { pipe("/dev/stdout"); };
+
+
+#
+# Filters
+#
+
+filter f_auth { facility(auth, authpriv); };
+filter f_boot { facility(local7); };
+filter f_cron { facility(cron); };
+filter f_emerg { level(emerg); };
+filter f_kern { facility(kern); };
+filter f_mail { facility(mail); };
+
+filter f_default {
+	level(info..emerg)
+	and not (facility(auth)
+		or facility(authpriv)
+		or facility(cron)
+		or facility(kern)
+		or facility(mail));
+};
+
+
+#
+# Logs
+#
+
+#log { source(s_sys); filter(f_auth); destination(d_auth); };
+#log { source(s_sys); filter(f_boot); destination(d_boot); };
+#log { source(s_sys); filter(f_cron); destination(d_cron); };
+#log { source(s_sys); filter(f_emerg); destination(d_cons_root); };
+#log { source(s_sys); filter(f_kern); destination(d_kern); };
+#log { source(s_sys); filter(f_mail); destination(d_mail); };
+#log { source(s_sys); filter(f_default); destination(d_mesg); };
+
+# customisation
+log { source(s_all); destination(d_stdout); };
+
+# Source additional configuration files (.conf extension only)
+#@include "/etc/syslog-ng/conf.d/*.conf"
\ No newline at end of file
diff --git a/src/test/docker/docker-syslog-ng-stdout/docker-entrypoint.sh b/src/test/docker/docker-syslog-ng-stdout/docker-entrypoint.sh
index be51a09..79e09ec 100755
--- a/src/test/docker/docker-syslog-ng-stdout/docker-entrypoint.sh
+++ b/src/test/docker/docker-syslog-ng-stdout/docker-entrypoint.sh
@@ -1,49 +1,4 @@
 #!/bin/bash
 
-# The following two methods are ripped from alpine's syslog-ng package.
-# This allows us (and a user) to customize the config with snippets.
-grep_syslog_conf_entries() {
-  local section="$1" FN filelist
-  grep -v '^#' /etc/syslog-ng/syslog-ng-${section}.std
-  filelist=$(find /etc/syslog-ng/ -maxdepth 1 -type f -name "syslog-ng-${section}.*" | grep -Ev ".backup|.std|~")
-  if [ $? -eq 0 ]
-  then
-    for FN in ${filelist}
-    do
-      grep -v '^#' $FN
-    done
-  fi
-}
-
-update() {
-  local fname='/etc/syslog-ng/syslog-ng.conf'
-  local f_tmp="/etc/syslog-ng/syslog-ng.conf.$$"
-  for ng_std in options source destination filter log
-  do
-    [ -f /etc/syslog-ng/syslog-ng-${ng_std}.std ] || exit 1
-  done
-  {
-    # create options entries
-    grep_syslog_conf_entries plugins
-    echo "options {"
-    grep_syslog_conf_entries options
-    echo "};"
-    # create source entries
-    echo "source s_all {"
-    grep_syslog_conf_entries source
-    echo "};"
-    # create destination entries
-    grep_syslog_conf_entries destination
-    # create filter entries
-    grep_syslog_conf_entries filter
-    # create log entries
-    grep_syslog_conf_entries log
-  } > $f_tmp
-  cp -p $f_tmp $fname
-  rm -f $f_tmp
-}
-
-update
-
 echo Starting "$@"
 exec "$@"
diff --git a/src/test/docker/gerrit/Dockerfile b/src/test/docker/gerrit/Dockerfile
index caac77f..33a7b5b 100644
--- a/src/test/docker/gerrit/Dockerfile
+++ b/src/test/docker/gerrit/Dockerfile
@@ -1,28 +1,52 @@
-FROM gerritcodereview/gerrit:3.7.7-ubuntu20
+FROM almalinux:8.5
 
-ENV GERRIT_BRANCH=stable-3.7
+# Install dependencies
+RUN yum -y install \
+    git \
+    java-11-openjdk \
+    procps \
+    sudo \
+    passwd \
+    gettext \
+    && yum -y clean all
 
-ENV GERRIT_CI_URL=https://gerrit-ci.gerritforge.com/job
+ENV GERRIT_VERSION=3.6
+ENV JAVA_HOME /usr/lib/jvm/jre-11-openjdk
 
-USER root
+# Add gerrit user
+RUN adduser -p -m --uid 1000 gerrit --home-dir /home/gerrit
+RUN echo "gerrit:gerrit" | chpasswd
+RUN echo "gerrit ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/gerrit && \
+    chmod 0440 /etc/sudoers.d/gerrit
 
-RUN apt update && apt install -y iputils-ping nmap curl lsof gettext net-tools sudo
+# Create gerrit installation directory
+RUN mkdir -p /var/gerrit && chown -R gerrit /var/gerrit
 
+ADD --chown=gerrit \
+    "https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-stable-$GERRIT_VERSION/lastSuccessfulBuild/artifact/gerrit/bazel-bin/release.war" \
+    /tmp/gerrit.war
+
+ADD --chown=gerrit \
+"https://gerrit-ci.gerritforge.com/job/plugin-javamelody-bazel-master-stable-$GERRIT_VERSION/lastSuccessfulBuild/artifact/bazel-bin/plugins/javamelody/javamelody.jar" \
+    /var/gerrit/plugins/javamelody.jar
+ADD --chown=gerrit \
+    "https://gerrit-ci.gerritforge.com/job/plugin-high-availability-bazel-stable-$GERRIT_VERSION/lastSuccessfulBuild/artifact/bazel-bin/plugins/high-availability/high-availability.jar" \
+    /var/gerrit/plugins/high-availability.jar
+
+ADD --chown=gerrit \
+    "https://repo1.maven.org/maven2/com/gerritforge/global-refdb/$GERRIT_VERSION.3.4/global-refdb-$GERRIT_VERSION.3.4.jar" \
+    /tmp
+
+ADD --chown=gerrit:gerrit ./wait-for-it.sh /bin
+
+# Change user
 USER gerrit
 
-ADD --chown=gerrit:gerrit $GERRIT_CI_URL/plugin-javamelody-bazel-master-$GERRIT_BRANCH/lastSuccessfulBuild/artifact/bazel-bin/plugins/javamelody/javamelody.jar /var/gerrit/plugins/javamelody.jar
-ADD --chown=gerrit:gerrit $GERRIT_CI_URL/plugin-high-availability-bazel-$GERRIT_BRANCH/lastSuccessfulBuild/artifact/bazel-bin/plugins/high-availability/high-availability.jar /var/gerrit/plugins/high-availability.jar
-ADD --chown=gerrit:gerrit $GERRIT_CI_URL/module-global-refdb-bazel-$GERRIT_BRANCH/lastSuccessfulBuild/artifact/bazel-bin/plugins/global-refdb/global-refdb.jar /var/gerrit/lib/global-refdb.jar
+# Expose ssh and http ports
+EXPOSE 29418 8080
+
+COPY --chown=gerrit ./entrypoint.sh /bin
 
 USER root
 
-ADD start.sh /bin/
-ADD wait-for-it.sh /bin/
-
-RUN rm -Rf /var/gerrit/{git,index,cache}/*
-
-ARG GERRIT_UID=1000
-RUN usermod -u ${GERRIT_UID} gerrit &> /dev/null
-
-ENTRYPOINT ["/usr/bin/env"]
-CMD /bin/start.sh
+ENTRYPOINT /bin/entrypoint.sh
diff --git a/src/test/docker/gerrit/entrypoint.sh b/src/test/docker/gerrit/entrypoint.sh
new file mode 100755
index 0000000..b382a3b
--- /dev/null
+++ b/src/test/docker/gerrit/entrypoint.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+if [[ ! -z "$WAIT_FOR" ]]
+then
+  wait-for-it.sh $WAIT_FOR -t 600 -- echo "$WAIT_FOR is up"
+fi
+
+chown -R gerrit /var/gerrit/etc
+sudo -u gerrit cp /var/gerrit/etc/gerrit.config.orig /var/gerrit/etc/gerrit.config
+sudo -u gerrit cp /var/gerrit/etc/high-availability.config.orig /var/gerrit/etc/high-availability.config
+
+
+echo "Init gerrit..."
+sudo -u gerrit java -jar /tmp/gerrit.war init -d /var/gerrit --batch --install-all-plugins
+chown -R gerrit: /var/gerrit/shareddir
+
+# required until regression is fixed, see https://groups.google.com/g/repo-discuss/c/DH-ftHMiCyE/m/qF88c6KMAAAJ
+echo "Copying global ref db jar into lib"
+sudo -u gerrit cp /tmp/global-refdb-*.jar /var/gerrit/lib
+
+echo "Reindexing Gerrit..."
+cd /var/gerrit && sudo -u gerrit java -jar /var/gerrit/bin/gerrit.war reindex -d /var/gerrit
+sudo -u gerrit git config -f /var/gerrit/etc/gerrit.config gerrit.canonicalWebUrl http://$HOSTNAME/
+sudo -u gerrit touch /var/gerrit/logs/{gc_log,error_log,httpd_log,sshd_log,replication_log} && tail -f /var/gerrit/logs/* | grep --line-buffered -v 'HEAD /' &
+
+echo "Running Gerrit ..."
+sudo -u gerrit /var/gerrit/bin/gerrit.sh run
diff --git a/src/test/docker/gerrit/start.sh b/src/test/docker/gerrit/start.sh
deleted file mode 100755
index d906628..0000000
--- a/src/test/docker/gerrit/start.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash -e
-
-if [[ ! -z "$WAIT_FOR" ]]
-then
-  wait-for-it.sh $WAIT_FOR -t 600 -- echo "$WAIT_FOR is up"
-fi
-
-sudo -u gerrit cp /var/gerrit/etc/gerrit.config.orig /var/gerrit/etc/gerrit.config
-sudo -u gerrit cp /var/gerrit/etc/high-availability.config.orig /var/gerrit/etc/high-availability.config
-
-if [[ ! -f /var/gerrit/etc/ssh_host_ed25519_key ]]
-then
-  echo "Initializing Gerrit site ..."
-  sudo -u gerrit java -jar /var/gerrit/bin/gerrit.war init -d /var/gerrit --batch
-  chown -R gerrit: /var/gerrit/shareddir
-fi
-
-echo "Reindexing Gerrit ..."
-sudo -u gerrit java -jar /var/gerrit/bin/gerrit.war reindex -d /var/gerrit
-sudo -u gerrit git config -f /var/gerrit/etc/gerrit.config gerrit.canonicalWebUrl http://$HOSTNAME/
-
-sudo -u gerrit touch /var/gerrit/logs/{gc_log,error_log,httpd_log,sshd_log,replication_log} && tail -f /var/gerrit/logs/* | grep --line-buffered -v 'HEAD /' &
-
-echo "Running Gerrit ..."
-sudo -u gerrit /var/gerrit/bin/gerrit.sh run
diff --git a/src/test/docker/nfs/Dockerfile b/src/test/docker/nfs/Dockerfile
new file mode 100644
index 0000000..19744f3
--- /dev/null
+++ b/src/test/docker/nfs/Dockerfile
@@ -0,0 +1,13 @@
+FROM erichough/nfs-server
+
+COPY exports.txt /etc/exports
+
+# To avoid ownership issues, the user must be the same betweeen the
+# server and the client, hence we are creating it explicitly in both.
+RUN adduser --disabled-password --gecos "" --uid 1000 gerrit
+
+# /var/gerrit/git is the shared directory
+RUN mkdir --parents /var/gerrit/git
+
+RUN chown gerrit:gerrit /var/lib/nfs
+RUN chown gerrit:gerrit /var/gerrit/git
\ No newline at end of file
diff --git a/src/test/docker/nfs/exports.txt b/src/test/docker/nfs/exports.txt
new file mode 100644
index 0000000..c31d586
--- /dev/null
+++ b/src/test/docker/nfs/exports.txt
@@ -0,0 +1 @@
+/var/gerrit/git 192.168.1.0/24(rw,no_subtree_check,insecure)
\ No newline at end of file
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
index b6986db..e158099 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/ConfigurationTest.java
@@ -17,9 +17,8 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.BATCH_THREAD_POOL_SIZE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Cache.CACHE_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Cache.PATTERN_KEY;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_NUM_STRIPED_LOCKS;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_THREAD_POOL_SIZE;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT_MS;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.DEFAULT_TIMEOUT;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Event.ALLOWED_LISTENERS;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Event.EVENT_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Forwarding.DEFAULT_SYNCHRONIZE;
@@ -48,7 +47,6 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Main.DEFAULT_SHARED_DIRECTORY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Main.MAIN_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Main.SHARED_DIRECTORY_KEY;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.NUM_STRIPED_LOCKS;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PEER_INFO_SECTION;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfo.DEFAULT_PEER_INFO_STRATEGY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfo.STRATEGY_KEY;
@@ -58,11 +56,10 @@
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStatic.URL_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.THREAD_POOL_SIZE_KEY;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.CLEANUP_INTERVAL_KEY;
-import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.DEFAULT_CLEANUP_INTERVAL_MS;
+import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.DEFAULT_CLEANUP_INTERVAL;
 import static com.ericsson.gerrit.plugins.highavailability.Configuration.Websession.WEBSESSION_SECTION;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.assertEquals;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration.PeerInfoStrategy;
@@ -74,6 +71,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.Duration;
 import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -216,24 +214,24 @@
 
   @Test
   public void testGetConnectionTimeout() throws Exception {
-    assertThat(getConfiguration().http().connectionTimeout()).isEqualTo(DEFAULT_TIMEOUT_MS);
+    assertThat(getConfiguration().http().connectionTimeout()).isEqualTo(DEFAULT_TIMEOUT);
 
     globalPluginConfig.setInt(HTTP_SECTION, null, CONNECTION_TIMEOUT_KEY, TIMEOUT);
-    assertThat(getConfiguration().http().connectionTimeout()).isEqualTo(TIMEOUT);
+    assertThat(getConfiguration().http().connectionTimeout().toMillis()).isEqualTo(TIMEOUT);
 
     globalPluginConfig.setString(HTTP_SECTION, null, CONNECTION_TIMEOUT_KEY, INVALID_INT);
-    assertThat(getConfiguration().http().connectionTimeout()).isEqualTo(DEFAULT_TIMEOUT_MS);
+    assertThat(getConfiguration().http().connectionTimeout()).isEqualTo(DEFAULT_TIMEOUT);
   }
 
   @Test
   public void testGetSocketTimeout() throws Exception {
-    assertThat(getConfiguration().http().socketTimeout()).isEqualTo(DEFAULT_TIMEOUT_MS);
+    assertThat(getConfiguration().http().socketTimeout()).isEqualTo(DEFAULT_TIMEOUT);
 
     globalPluginConfig.setInt(HTTP_SECTION, null, SOCKET_TIMEOUT_KEY, TIMEOUT);
-    assertThat(getConfiguration().http().socketTimeout()).isEqualTo(TIMEOUT);
+    assertThat(getConfiguration().http().socketTimeout().toMillis()).isEqualTo(TIMEOUT);
 
     globalPluginConfig.setString(HTTP_SECTION, null, SOCKET_TIMEOUT_KEY, INVALID_INT);
-    assertThat(getConfiguration().http().socketTimeout()).isEqualTo(DEFAULT_TIMEOUT_MS);
+    assertThat(getConfiguration().http().socketTimeout()).isEqualTo(DEFAULT_TIMEOUT);
   }
 
   @Test
@@ -245,6 +243,12 @@
 
     globalPluginConfig.setString(HTTP_SECTION, null, MAX_TRIES_KEY, INVALID_INT);
     assertThat(getConfiguration().http().maxTries()).isEqualTo(DEFAULT_MAX_TRIES);
+
+    globalPluginConfig.setString(HTTP_SECTION, null, MAX_TRIES_KEY, "0");
+    assertThat(getConfiguration().http().maxTries()).isEqualTo(DEFAULT_MAX_TRIES);
+
+    globalPluginConfig.setString(HTTP_SECTION, null, MAX_TRIES_KEY, "-1");
+    assertThat(getConfiguration().http().maxTries()).isEqualTo(DEFAULT_MAX_TRIES);
   }
 
   @Test
@@ -252,7 +256,7 @@
     assertThat(getConfiguration().http().retryInterval()).isEqualTo(DEFAULT_RETRY_INTERVAL);
 
     globalPluginConfig.setInt(HTTP_SECTION, null, RETRY_INTERVAL_KEY, RETRY_INTERVAL);
-    assertThat(getConfiguration().http().retryInterval()).isEqualTo(RETRY_INTERVAL);
+    assertThat(getConfiguration().http().retryInterval().toMillis()).isEqualTo(RETRY_INTERVAL);
 
     globalPluginConfig.setString(HTTP_SECTION, null, RETRY_INTERVAL_KEY, INVALID_INT);
     assertThat(getConfiguration().http().retryInterval()).isEqualTo(DEFAULT_RETRY_INTERVAL);
@@ -297,17 +301,6 @@
   }
 
   @Test
-  public void testGetCacheThreadPoolSize() throws Exception {
-    assertThat(getConfiguration().cache().threadPoolSize()).isEqualTo(DEFAULT_THREAD_POOL_SIZE);
-
-    globalPluginConfig.setInt(CACHE_SECTION, null, THREAD_POOL_SIZE_KEY, THREAD_POOL_SIZE);
-    assertThat(getConfiguration().cache().threadPoolSize()).isEqualTo(THREAD_POOL_SIZE);
-
-    globalPluginConfig.setString(CACHE_SECTION, null, THREAD_POOL_SIZE_KEY, INVALID_INT);
-    assertThat(getConfiguration().cache().threadPoolSize()).isEqualTo(DEFAULT_THREAD_POOL_SIZE);
-  }
-
-  @Test
   public void testGetCacheSynchronize() throws Exception {
     assertThat(getConfiguration().cache().synchronize()).isEqualTo(DEFAULT_SYNCHRONIZE);
 
@@ -441,10 +434,10 @@
   @Test
   public void testGetCleanupInterval() throws Exception {
     assertThat(getConfiguration().websession().cleanupInterval())
-        .isEqualTo(DEFAULT_CLEANUP_INTERVAL_MS);
+        .isEqualTo(DEFAULT_CLEANUP_INTERVAL);
 
     globalPluginConfig.setString(WEBSESSION_SECTION, null, CLEANUP_INTERVAL_KEY, "30 seconds");
-    assertThat(getConfiguration().websession().cleanupInterval()).isEqualTo(SECONDS.toMillis(30));
+    assertThat(getConfiguration().websession().cleanupInterval()).isEqualTo(Duration.ofSeconds(30));
   }
 
   @Test
@@ -482,14 +475,6 @@
   }
 
   @Test
-  public void testGetIndexNumStripedLocks() throws Exception {
-    assertThat(getConfiguration().index().numStripedLocks()).isEqualTo(DEFAULT_NUM_STRIPED_LOCKS);
-
-    globalPluginConfig.setInt(INDEX_SECTION, null, NUM_STRIPED_LOCKS, 100);
-    assertThat(getConfiguration().index().numStripedLocks()).isEqualTo(100);
-  }
-
-  @Test
   public void testGetIndexSynchronizeForced() throws Exception {
     assertThat(getConfiguration().index().synchronizeForced())
         .isEqualTo(DEFAULT_SYNCHRONIZE_FORCED);
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandlerTest.java
index e6633d8..3d091a0 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheEvictionHandlerTest.java
@@ -14,7 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.cache;
 
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
@@ -25,7 +25,6 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.concurrent.Executor;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
@@ -35,11 +34,9 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class CacheEvictionHandlerTest {
-  @Mock private Executor executorMock;
   @Mock private Forwarder forwarder;
   @Mock private PluginConfigFactory pluginConfigFactoryMock;
 
-  private static final String PLUGIN_NAME = "high-availability";
   private static final Path SITE_PATH = Paths.get("/site_path");
   private CachePatternMatcher defaultCacheMatcher;
 
@@ -52,9 +49,9 @@
   @Test
   public void shouldNotPublishAccountsCacheEvictions() {
     CacheEvictionHandler<String, String> handler =
-        new CacheEvictionHandler<>(forwarder, executorMock, PLUGIN_NAME, defaultCacheMatcher);
+        new CacheEvictionHandler<>(forwarder, defaultCacheMatcher);
     handler.onRemoval(
         "test", "accounts", RemovalNotification.create("test", "accounts", RemovalCause.EXPLICIT));
-    verifyZeroInteractions(executorMock);
+    verifyNoInteractions(forwarder);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProviderTest.java
deleted file mode 100644
index 7f7071b..0000000
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/CacheExecutorProviderTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2015 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.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.google.gerrit.server.git.WorkQueue;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Answers;
-import org.mockito.Mock;
-import org.mockito.junit.MockitoJUnitRunner;
-
-@RunWith(MockitoJUnitRunner.class)
-public class CacheExecutorProviderTest {
-
-  @Mock private ScheduledThreadPoolExecutor executorMock;
-
-  private CacheExecutorProvider cacheExecutorProvider;
-
-  @Before
-  public void setUp() throws Exception {
-    WorkQueue workQueueMock = mock(WorkQueue.class);
-    when(workQueueMock.createQueue(4, "Forward-Cache-Eviction-Event")).thenReturn(executorMock);
-    Configuration configMock = mock(Configuration.class, Answers.RETURNS_DEEP_STUBS);
-    when(configMock.cache().threadPoolSize()).thenReturn(4);
-
-    cacheExecutorProvider = new CacheExecutorProvider(workQueueMock, configMock);
-  }
-
-  @Test
-  public void shouldReturnExecutor() throws Exception {
-    assertThat(cacheExecutorProvider.get()).isEqualTo(executorMock);
-  }
-
-  @Test
-  public void testStop() throws Exception {
-    cacheExecutorProvider.start();
-    assertThat(cacheExecutorProvider.get()).isEqualTo(executorMock);
-    cacheExecutorProvider.stop();
-    verify(executorMock).shutdown();
-    assertThat(cacheExecutorProvider.get()).isNull();
-  }
-}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandlerTest.java
index ec114f7..1bd2aed 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/cache/ProjectListUpdateHandlerTest.java
@@ -14,16 +14,13 @@
 
 package com.ericsson.gerrit.plugins.highavailability.cache;
 
-import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
-import com.ericsson.gerrit.plugins.highavailability.cache.ProjectListUpdateHandler.ProjectListUpdateTask;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
-import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import org.junit.Before;
@@ -34,15 +31,13 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class ProjectListUpdateHandlerTest {
-  private static final String PLUGIN_NAME = "high-availability";
-
   private ProjectListUpdateHandler handler;
 
   @Mock private Forwarder forwarder;
 
   @Before
   public void setUp() {
-    handler = new ProjectListUpdateHandler(forwarder, MoreExecutors.directExecutor(), PLUGIN_NAME);
+    handler = new ProjectListUpdateHandler(forwarder);
   }
 
   @Test
@@ -69,23 +64,6 @@
     handler.onNewProjectCreated(mock(NewProjectCreatedListener.Event.class));
     handler.onProjectDeleted(mock(ProjectDeletedListener.Event.class));
     Context.unsetForwardedEvent();
-    verifyZeroInteractions(forwarder);
-  }
-
-  @Test
-  public void testProjectUpdateTaskToString() throws Exception {
-    String projectName = "someProjectName";
-    ProjectListUpdateTask task = handler.new ProjectListUpdateTask(projectName, false);
-    assertThat(task.toString())
-        .isEqualTo(
-            String.format(
-                "[%s] Update project list in target instance: add '%s'", PLUGIN_NAME, projectName));
-
-    task = handler.new ProjectListUpdateTask(projectName, true);
-    assertThat(task.toString())
-        .isEqualTo(
-            String.format(
-                "[%s] Update project list in target instance: remove '%s'",
-                PLUGIN_NAME, projectName));
+    verifyNoInteractions(forwarder);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProviderTest.java
deleted file mode 100644
index 6e666ed..0000000
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventExecutorProviderTest.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2016 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.event;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.google.gerrit.server.git.WorkQueue;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-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 EventExecutorProviderTest {
-  @Mock private ScheduledThreadPoolExecutor executorMock;
-  private EventExecutorProvider eventsExecutorProvider;
-
-  @Before
-  public void setUp() throws Exception {
-    WorkQueue workQueueMock = mock(WorkQueue.class);
-    when(workQueueMock.createQueue(1, "Forward-Stream-Event")).thenReturn(executorMock);
-    eventsExecutorProvider = new EventExecutorProvider(workQueueMock);
-  }
-
-  @Test
-  public void shouldReturnExecutor() throws Exception {
-    assertThat(eventsExecutorProvider.get()).isEqualTo(executorMock);
-  }
-
-  @Test
-  public void testStop() throws Exception {
-    eventsExecutorProvider.start();
-    assertThat(eventsExecutorProvider.get()).isEqualTo(executorMock);
-    eventsExecutorProvider.stop();
-    verify(executorMock).shutdown();
-    assertThat(eventsExecutorProvider.get()).isNull();
-  }
-}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandlerTest.java
index 663885f..988064e 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/event/EventHandlerTest.java
@@ -14,18 +14,14 @@
 
 package com.ericsson.gerrit.plugins.highavailability.event;
 
-import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
-import com.ericsson.gerrit.plugins.highavailability.event.EventHandler.EventTask;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
-import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.ProjectEvent;
-import com.google.gerrit.server.events.RefUpdatedEvent;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -34,15 +30,13 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class EventHandlerTest {
-  private static final String PLUGIN_NAME = "high-availability";
-
   private EventHandler eventHandler;
 
   @Mock private Forwarder forwarder;
 
   @Before
   public void setUp() {
-    eventHandler = new EventHandler(forwarder, MoreExecutors.directExecutor(), PLUGIN_NAME);
+    eventHandler = new EventHandler(forwarder);
   }
 
   @Test
@@ -55,7 +49,7 @@
   @Test
   public void shouldNotForwardNonProjectEvent() throws Exception {
     eventHandler.onEvent(mock(Event.class));
-    verifyZeroInteractions(forwarder);
+    verifyNoInteractions(forwarder);
   }
 
   @Test
@@ -63,15 +57,6 @@
     Context.setForwardedEvent(true);
     eventHandler.onEvent(mock(ProjectEvent.class));
     Context.unsetForwardedEvent();
-    verifyZeroInteractions(forwarder);
-  }
-
-  @Test
-  public void tesEventTaskToString() throws Exception {
-    Event event = new RefUpdatedEvent();
-    EventTask task = eventHandler.new EventTask(event);
-    assertThat(task.toString())
-        .isEqualTo(
-            String.format("[%s] Send event '%s' to target instance", PLUGIN_NAME, event.type));
+    verifyNoInteractions(forwarder);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedAwareEventBrokerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedAwareEventBrokerTest.java
index 4c639ba..12c0364 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedAwareEventBrokerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedAwareEventBrokerTest.java
@@ -16,7 +16,7 @@
 
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.events.Event;
@@ -59,6 +59,6 @@
     } finally {
       Context.unsetForwardedEvent();
     }
-    verifyZeroInteractions(listenerMock);
+    verifyNoInteractions(listenerMock);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandlerTest.java
index 0e9cee8..e549e50 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexAccountHandlerTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
 
@@ -46,7 +47,7 @@
 
   @Test
   public void testSuccessfulIndexing() throws Exception {
-    handler.index(id, Operation.INDEX, Optional.empty());
+    handler.index(id, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     verify(indexerMock).index(id);
   }
 
@@ -55,7 +56,7 @@
     UnsupportedOperationException thrown =
         assertThrows(
             UnsupportedOperationException.class,
-            () -> handler.index(id, Operation.DELETE, Optional.empty()));
+            () -> handler.index(id, Operation.DELETE, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().contains("Delete from account index not supported");
   }
 
@@ -73,7 +74,7 @@
         .index(id);
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    handler.index(id, Operation.INDEX, Optional.empty());
+    handler.index(id, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(indexerMock).index(id);
@@ -92,7 +93,9 @@
 
     assertThat(Context.isForwardedEvent()).isFalse();
     IOException thrown =
-        assertThrows(IOException.class, () -> handler.index(id, Operation.INDEX, Optional.empty()));
+        assertThrows(
+            IOException.class,
+            () -> handler.index(id, Operation.INDEX, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().isEqualTo("someMessage");
     assertThat(Context.isForwardedEvent()).isFalse();
 
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
index fe3e3fb..427985a 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
@@ -16,7 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.mockito.Answers.RETURNS_DEEP_STUBS;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -26,13 +29,18 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexingHandler.Operation;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeChecker;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl;
+import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutorProvider;
+import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexFailsafeExecutorProvider;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.OneOffRequestContext;
+import dev.failsafe.FailsafeExecutor;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Optional;
-import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -53,50 +61,57 @@
 
   @Mock private ChangeIndexer indexerMock;
   @Mock private ChangeNotes changeNotes;
-  @Mock private Configuration configMock;
-  @Mock private Configuration.Index indexMock;
-  @Mock private ScheduledExecutorService indexExecutorMock;
+
+  @Mock(answer = RETURNS_DEEP_STUBS)
+  private Configuration configMock;
+
   @Mock private OneOffRequestContext ctxMock;
   @Mock private ChangeCheckerImpl.Factory changeCheckerFactoryMock;
   @Mock private ChangeChecker changeCheckerAbsentMock;
   @Mock private ChangeChecker changeCheckerPresentMock;
+  @Mock private ForwardedIndexExecutorProvider indexExecutorProviderMock;
   private ForwardedIndexChangeHandler handler;
   private Change.Id id;
 
   @Before
   public void setUp() throws Exception {
     id = Change.id(TEST_CHANGE_NUMBER);
-    when(configMock.index()).thenReturn(indexMock);
+    when(configMock.index().threadPoolSize()).thenReturn(4);
+    when(configMock.index().maxTries()).thenReturn(3);
+    when(configMock.index().retryInterval()).thenReturn(Duration.ofMillis(10));
     when(changeCheckerFactoryMock.create(any())).thenReturn(changeCheckerAbsentMock);
+    when(indexExecutorProviderMock.get()).thenReturn(Executors.newScheduledThreadPool(2));
+    FailsafeExecutor<Boolean> indexExecutor =
+        new ForwardedIndexFailsafeExecutorProvider(configMock, indexExecutorProviderMock).get();
     handler =
         new ForwardedIndexChangeHandler(
-            indexerMock, configMock, indexExecutorMock, ctxMock, changeCheckerFactoryMock);
+            indexerMock, indexExecutor, ctxMock, changeCheckerFactoryMock);
   }
 
   @Test
   public void changeIsIndexedWhenUpToDate() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_EXISTS, CHANGE_UP_TO_DATE);
-    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty());
+    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     verify(indexerMock, times(1)).index(any(ChangeNotes.class));
   }
 
   @Test
   public void changeIsStillIndexedEvenWhenOutdated() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_EXISTS, CHANGE_OUTDATED);
-    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.of(new IndexEvent()));
-    verify(indexerMock, times(1)).index(any(ChangeNotes.class));
+    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.of(new IndexEvent())).get(10, SECONDS);
+    verify(indexerMock, atLeast(1)).index(any(ChangeNotes.class));
   }
 
   @Test
   public void changeIsDeletedFromIndex() throws Exception {
-    handler.index(TEST_CHANGE_ID, Operation.DELETE, Optional.empty());
+    handler.index(TEST_CHANGE_ID, Operation.DELETE, Optional.empty()).get(10, SECONDS);
     verify(indexerMock, times(1)).delete(id);
   }
 
   @Test
   public void changeToIndexDoesNotExist() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_DOES_NOT_EXIST, CHANGE_OUTDATED);
-    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty());
+    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     verify(indexerMock, times(0)).delete(id);
   }
 
@@ -115,7 +130,7 @@
         .index(any(ChangeNotes.class));
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty());
+    handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(indexerMock, times(1)).index(any(ChangeNotes.class));
@@ -134,11 +149,12 @@
         .index(any(ChangeNotes.class));
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    IOException thrown =
+    ExecutionException thrown =
         assertThrows(
-            IOException.class,
-            () -> handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()));
-    assertThat(thrown).hasMessageThat().isEqualTo("someMessage");
+            ExecutionException.class,
+            () ->
+                handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS));
+    assertThat(thrown.getCause()).hasMessageThat().isEqualTo("someMessage");
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(indexerMock, times(1)).index(any(ChangeNotes.class));
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandlerTest.java
index 5fdb151..babe1d4 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexGroupHandlerTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
 
@@ -46,7 +47,7 @@
 
   @Test
   public void testSuccessfulIndexing() throws Exception {
-    handler.index(uuid, Operation.INDEX, Optional.empty());
+    handler.index(uuid, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     verify(indexerMock).index(uuid);
   }
 
@@ -55,7 +56,7 @@
     UnsupportedOperationException thrown =
         assertThrows(
             UnsupportedOperationException.class,
-            () -> handler.index(uuid, Operation.DELETE, Optional.empty()));
+            () -> handler.index(uuid, Operation.DELETE, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().contains("Delete from group index not supported");
   }
 
@@ -73,7 +74,7 @@
         .index(uuid);
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    handler.index(uuid, Operation.INDEX, Optional.empty());
+    handler.index(uuid, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(indexerMock).index(uuid);
@@ -93,7 +94,8 @@
     assertThat(Context.isForwardedEvent()).isFalse();
     IOException thrown =
         assertThrows(
-            IOException.class, () -> handler.index(uuid, Operation.INDEX, Optional.empty()));
+            IOException.class,
+            () -> handler.index(uuid, Operation.INDEX, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().isEqualTo("someMessage");
     assertThat(Context.isForwardedEvent()).isFalse();
 
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandlerTest.java
index 0c896b3..f91a83e 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexProjectHandlerTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
 
@@ -46,7 +47,7 @@
 
   @Test
   public void testSuccessfulIndexing() throws Exception {
-    handler.index(nameKey, Operation.INDEX, Optional.empty());
+    handler.index(nameKey, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     verify(indexerMock).index(nameKey);
   }
 
@@ -55,7 +56,7 @@
     UnsupportedOperationException thrown =
         assertThrows(
             UnsupportedOperationException.class,
-            () -> handler.index(nameKey, Operation.DELETE, Optional.empty()));
+            () -> handler.index(nameKey, Operation.DELETE, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().contains("Delete from project index not supported");
   }
 
@@ -73,7 +74,7 @@
         .index(nameKey);
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    handler.index(nameKey, Operation.INDEX, Optional.empty());
+    handler.index(nameKey, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(indexerMock).index(nameKey);
@@ -93,7 +94,8 @@
     assertThat(Context.isForwardedEvent()).isFalse();
     IOException thrown =
         assertThrows(
-            IOException.class, () -> handler.index(nameKey, Operation.INDEX, Optional.empty()));
+            IOException.class,
+            () -> handler.index(nameKey, Operation.INDEX, Optional.empty()).get(10, SECONDS));
     assertThat(thrown).hasMessageThat().isEqualTo("someMessage");
     assertThat(Context.isForwardedEvent()).isFalse();
 
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializerTest.java
new file mode 100644
index 0000000..2f5c5b7
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/CommandDeserializerTest.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.ericsson.gerrit.plugins.highavailability.cache.Constants;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.CacheKeyJsonParser;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventGsonProvider;
+import com.google.gerrit.server.events.ProjectCreatedEvent;
+import com.google.gson.Gson;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CommandDeserializerTest {
+
+  private Gson gson;
+  private CacheKeyJsonParser cacheKeyParser;
+
+  @Before
+  public void setUp() {
+    Gson eventGson = new EventGsonProvider().get();
+    this.gson = new JGroupsForwarderModule().buildJGroupsGson(eventGson);
+    this.cacheKeyParser = new CacheKeyJsonParser(eventGson);
+  }
+
+  @Test
+  public void indexAccount() {
+    Command cmd = gson.fromJson("{type: 'index-account', id: 100}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexAccount.class);
+    IndexAccount index = (IndexAccount) cmd;
+    assertThat(index.getId()).isEqualTo(100);
+  }
+
+  @Test
+  public void updateChangeCommand() {
+    Command cmd =
+        gson.fromJson("{type: 'update-change', projectName: 'foo', id: 100}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexChange.Update.class);
+    IndexChange.Update update = (IndexChange.Update) cmd;
+    assertThat(update.getId()).isEqualTo("foo~100");
+    assertThat(update.isBatch()).isFalse();
+  }
+
+  @Test
+  public void batchUpdateChangeCommand() {
+    Command cmd =
+        gson.fromJson(
+            "{type: 'update-change', projectName: 'foo', id: 100, batchMode: 'true'}",
+            Command.class);
+    assertThat(cmd).isInstanceOf(IndexChange.Update.class);
+    IndexChange.Update update = (IndexChange.Update) cmd;
+    assertThat(update.getId()).isEqualTo("foo~100");
+    assertThat(update.isBatch()).isTrue();
+  }
+
+  @Test
+  public void deleteChangeCommand() {
+    Command cmd = gson.fromJson("{type: 'delete-change', id: 100}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexChange.Delete.class);
+    IndexChange.Delete delete = (IndexChange.Delete) cmd;
+    assertThat(delete.getId()).isEqualTo("~100");
+
+    cmd = gson.fromJson("{type: 'delete-change', projectName: 'foo', id: 100}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexChange.Delete.class);
+    delete = (IndexChange.Delete) cmd;
+    assertThat(delete.getId()).isEqualTo("foo~100");
+  }
+
+  @Test
+  public void indexGroup() {
+    Command cmd = gson.fromJson("{type: 'index-group', uuid: 'foo'}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexGroup.class);
+    IndexGroup index = (IndexGroup) cmd;
+    assertThat(index.getUuid()).isEqualTo("foo");
+  }
+
+  @Test
+  public void indexProject() {
+    Command cmd = gson.fromJson("{type: 'index-project', projectName: 'foo'}", Command.class);
+    assertThat(cmd).isInstanceOf(IndexProject.class);
+    IndexProject index = (IndexProject) cmd;
+    assertThat(index.getProjectName()).isEqualTo("foo");
+  }
+
+  @Test
+  public void postEvent() {
+    Command cmd =
+        gson.fromJson(
+            "{event: {projectName : 'foo', headName : 'refs/heads/master', type :"
+                + " 'project-created', eventCreatedOn:1505898779}, type : 'post-event'}",
+            Command.class);
+    assertThat(cmd).isInstanceOf(PostEvent.class);
+    Event e = ((PostEvent) cmd).getEvent();
+    assertThat(e).isInstanceOf(ProjectCreatedEvent.class);
+    assertThat(((ProjectCreatedEvent) e).projectName).isEqualTo("foo");
+  }
+
+  @Test
+  public void evictCache() {
+    Project.NameKey nameKey = Project.nameKey("foo");
+    String keyJson = gson.toJson(nameKey);
+    Command cmd =
+        gson.fromJson(
+            String.format(
+                "{type: '%s', cacheName: '%s', keyJson: '%s'}",
+                EvictCache.TYPE, Constants.PROJECTS, keyJson),
+            EvictCache.class);
+    assertThat(cmd).isInstanceOf(EvictCache.class);
+    EvictCache evict = (EvictCache) cmd;
+
+    Object cacheKey = cacheKeyParser.fromJson(Constants.PROJECTS, evict.getKeyJson());
+    assertThat(cacheKey).isInstanceOf(Project.NameKey.class);
+    assertThat(cacheKey).isEqualTo(nameKey);
+  }
+
+  @Test
+  public void addToProjectList() {
+    Command cmd = gson.fromJson("{type: 'add-to-project-list', projectName: 'foo'}", Command.class);
+    assertThat(cmd).isInstanceOf(AddToProjectList.class);
+    AddToProjectList addToProjectList = (AddToProjectList) cmd;
+    assertThat(addToProjectList.getProjectName()).isEqualTo("foo");
+  }
+
+  @Test
+  public void removeFromProjectList() {
+    Command cmd =
+        gson.fromJson("{type: 'remove-from-project-list', projectName: 'foo'}", Command.class);
+    assertThat(cmd).isInstanceOf(RemoveFromProjectList.class);
+    RemoveFromProjectList removeFromProjectList = (RemoveFromProjectList) cmd;
+    assertThat(removeFromProjectList.getProjectName()).isEqualTo("foo");
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderTest.java
new file mode 100644
index 0000000..082b5d1
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarderTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.gerrit.server.events.EventGsonProvider;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gson.Gson;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import org.jgroups.Address;
+import org.jgroups.blocks.MessageDispatcher;
+import org.jgroups.util.Rsp;
+import org.jgroups.util.RspList;
+import org.jgroups.util.UUID;
+import org.junit.Before;
+import org.junit.Test;
+
+public class JGroupsForwarderTest {
+
+  private static final int MAX_TRIES = 3;
+  private static final int THREAD_POOLS_SIZE = 4;
+
+  private static final Address A1 = new UUID(1, 1);
+  private static final Address A2 = new UUID(2, 2);
+  private static final Rsp<Object> RSP_OK = new Rsp<>(true);
+  private static final Rsp<Object> RSP_FAIL = new Rsp<>(false);
+
+  private MessageDispatcher dispatcher;
+  private JGroupsForwarder forwarder;
+
+  @Before
+  public void setUp() throws Exception {
+    Gson eventGson = new EventGsonProvider().get();
+    Gson gson = new JGroupsForwarderModule().buildJGroupsGson(eventGson);
+    Configuration cfg = mock(Configuration.class, RETURNS_DEEP_STUBS);
+    when(cfg.jgroups().maxTries()).thenReturn(MAX_TRIES);
+    when(cfg.jgroups().retryInterval()).thenReturn(Duration.ofMillis(1));
+    when(cfg.jgroups().threadPoolSize()).thenReturn(THREAD_POOLS_SIZE);
+
+    dispatcher = mock(MessageDispatcher.class, RETURNS_DEEP_STUBS);
+    when(dispatcher.getChannel().getView().size()).thenReturn(2);
+    when(dispatcher.getChannel().getView().getMembers()).thenReturn(List.of(A1, A2));
+
+    WorkQueue workQueue = mock(WorkQueue.class);
+    when(workQueue.createQueue(THREAD_POOLS_SIZE, "JGroupsForwarder"))
+        .thenReturn(Executors.newScheduledThreadPool(THREAD_POOLS_SIZE));
+    forwarder =
+        new JGroupsForwarder(
+            dispatcher, cfg, gson, new FailsafeExecutorProvider(cfg, workQueue).get());
+  }
+
+  @Test
+  public void castMessageOK_returnsTrue() throws Exception {
+    RspList<Object> OK = new RspList<>(Map.of(A1, RSP_OK, A2, RSP_OK));
+    when(dispatcher.castMessage(any(), any(), any())).thenReturn(OK);
+
+    CompletableFuture<Boolean> result = forwarder.indexAccount(100, null);
+    assertThat(result.get()).isTrue();
+    verify(dispatcher, times(1)).castMessage(any(), any(), any());
+  }
+
+  @SuppressWarnings("unchecked")
+  @Test
+  public void castMessageRetriesWithSucess_returnsTrue() throws Exception {
+    RspList<Object> OK = new RspList<>(Map.of(A1, RSP_OK, A2, RSP_OK));
+    RspList<Object> FAIL = new RspList<>(Map.of(A1, RSP_OK, A2, RSP_FAIL));
+    when(dispatcher.castMessage(any(), any(), any())).thenReturn(FAIL, OK);
+
+    CompletableFuture<Boolean> result = forwarder.indexAccount(100, null);
+    assertThat(result.get()).isTrue();
+    verify(dispatcher, times(2)).castMessage(any(), any(), any());
+  }
+
+  @SuppressWarnings("unchecked")
+  @Test
+  public void castMessageFailsMaxTriesTimes_returnsFalse() throws Exception {
+    RspList<Object> FAIL = new RspList<>(Map.of(A1, RSP_FAIL, A2, RSP_FAIL));
+    // return FAIL x MAX_TRIES
+    when(dispatcher.castMessage(any(), any(), any())).thenReturn(FAIL, FAIL, FAIL);
+
+    CompletableFuture<Boolean> result = forwarder.indexAccount(100, null);
+    assertThat(result.get()).isFalse();
+    verify(dispatcher, times(MAX_TRIES)).castMessage(any(), any(), any());
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessorTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessorTest.java
new file mode 100644
index 0000000..c3114de
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/MessageProcessorTest.java
@@ -0,0 +1,208 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.ericsson.gerrit.plugins.highavailability.forwarder.CacheEntry;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.CacheNotFoundException;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedCacheEvictionHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedEventHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexAccountHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexBatchChangeHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexChangeHandler;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedIndexingHandler.Operation;
+import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedProjectListUpdateHandler;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventGsonProvider;
+import com.google.gerrit.server.events.EventTypes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gson.Gson;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.jgroups.ObjectMessage;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+public class MessageProcessorTest {
+
+  private MessageProcessor processor;
+  private Gson gson;
+
+  private ForwardedIndexChangeHandler indexChangeHandler;
+  private ForwardedIndexBatchChangeHandler indexBatchChangeHandler;
+  private ForwardedIndexAccountHandler indexAccountHandler;
+  private ForwardedCacheEvictionHandler cacheEvictionHandler;
+  private ForwardedEventHandler eventHandler;
+  private ForwardedProjectListUpdateHandler projectListUpdateHandler;
+
+  private List<Object> allHandlers = new ArrayList<>();
+
+  @Before
+  public void setUp() {
+    Gson eventGson = new EventGsonProvider().get();
+    gson = new JGroupsForwarderModule().buildJGroupsGson(eventGson);
+
+    indexChangeHandler = createHandlerMock(ForwardedIndexChangeHandler.class);
+    indexBatchChangeHandler = createHandlerMock(ForwardedIndexBatchChangeHandler.class);
+    indexAccountHandler = createHandlerMock(ForwardedIndexAccountHandler.class);
+    cacheEvictionHandler = createHandlerMock(ForwardedCacheEvictionHandler.class);
+    eventHandler = createHandlerMock(ForwardedEventHandler.class);
+    projectListUpdateHandler = createHandlerMock(ForwardedProjectListUpdateHandler.class);
+
+    processor =
+        new MessageProcessor(
+            gson,
+            indexChangeHandler,
+            indexBatchChangeHandler,
+            indexAccountHandler,
+            cacheEvictionHandler,
+            eventHandler,
+            projectListUpdateHandler);
+  }
+
+  private <T> T createHandlerMock(Class<T> handlerClass) {
+    T handlerMock = mock(handlerClass);
+    allHandlers.add(handlerMock);
+    return handlerMock;
+  }
+
+  @Test
+  public void indexAccount() throws IOException {
+    int ACCOUNT_ID = 100;
+
+    IndexAccount cmd = new IndexAccount(ACCOUNT_ID);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(indexAccountHandler, times(1))
+        .index(Account.id(ACCOUNT_ID), Operation.INDEX, Optional.empty());
+    verifyOtherHandlersNotUsed(indexAccountHandler);
+  }
+
+  @Test
+  public void indexChange() throws IOException {
+    String PROJECT = "foo";
+    int CHANGE_ID = 100;
+
+    IndexChange.Update cmd = new IndexChange.Update(PROJECT, CHANGE_ID);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(indexChangeHandler, times(1))
+        .index(PROJECT + "~" + Change.id(CHANGE_ID), Operation.INDEX, Optional.empty());
+    verifyOtherHandlersNotUsed(indexChangeHandler);
+  }
+
+  @Test
+  public void indexChangeBatchMode() throws IOException {
+    String PROJECT = "foo";
+    int CHANGE_ID = 100;
+
+    IndexChange.Update cmd = new IndexChange.Update(PROJECT, CHANGE_ID, true);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(indexBatchChangeHandler, times(1))
+        .index(PROJECT + "~" + Change.id(CHANGE_ID), Operation.INDEX, Optional.empty());
+    verifyOtherHandlersNotUsed(indexBatchChangeHandler);
+  }
+
+  @Test
+  public void deleteChangeFromIndex() throws IOException {
+    String PROJECT = "foo";
+    int CHANGE_ID = 100;
+
+    IndexChange.Delete cmd = new IndexChange.Delete(PROJECT, CHANGE_ID);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(indexChangeHandler, times(1))
+        .index(PROJECT + "~" + Change.id(CHANGE_ID), Operation.DELETE, Optional.empty());
+    verifyOtherHandlersNotUsed(indexChangeHandler);
+  }
+
+  @Test
+  public void evictCache() throws CacheNotFoundException {
+    String CACHE = "foo";
+    String KEY_JSON = gson.toJson(100);
+
+    EvictCache cmd = new EvictCache(CACHE, KEY_JSON);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    CacheEntry e = CacheEntry.from(CACHE, KEY_JSON);
+    verify(cacheEvictionHandler, times(1)).evict(e);
+    verifyOtherHandlersNotUsed(cacheEvictionHandler);
+  }
+
+  @Test
+  public void postEvent() throws PermissionBackendException {
+    String FOO = "foo";
+    int BAR = 100;
+
+    EventTypes.register(TestEvent.TYPE, TestEvent.class);
+    TestEvent event = new TestEvent(FOO, BAR);
+    PostEvent cmd = new PostEvent(event);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
+    verify(eventHandler, times(1)).dispatch(captor.capture());
+    assertThat(captor.getValue()).isInstanceOf(TestEvent.class);
+    TestEvent v = (TestEvent) captor.getValue();
+    assertThat(v.foo).isEqualTo(FOO);
+    assertThat(v.bar).isEqualTo(BAR);
+    verifyOtherHandlersNotUsed(eventHandler);
+  }
+
+  @Test
+  public void addToProjectList() throws IOException {
+    String PROJECT = "foo";
+
+    AddToProjectList cmd = new AddToProjectList(PROJECT);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(projectListUpdateHandler, times(1)).update(PROJECT, false);
+    verifyOtherHandlersNotUsed(projectListUpdateHandler);
+  }
+
+  @Test
+  public void removeFromProjectList() throws IOException {
+    String PROJECT = "foo";
+
+    RemoveFromProjectList cmd = new RemoveFromProjectList(PROJECT);
+    assertThat(processor.handle(new ObjectMessage(null, gson.toJson(cmd)))).isEqualTo(true);
+    verify(projectListUpdateHandler, times(1)).update(PROJECT, true);
+    verifyOtherHandlersNotUsed(projectListUpdateHandler);
+  }
+
+  private void verifyOtherHandlersNotUsed(Object onlyUsedHandler) {
+    for (Object handler : allHandlers) {
+      if (handler != onlyUsedHandler) {
+        verifyNoInteractions(handler);
+      }
+    }
+  }
+
+  private static class TestEvent extends Event {
+    static final String TYPE = "test-event";
+
+    String foo;
+    int bar;
+
+    TestEvent(String foo, int bar) {
+      super(TYPE);
+      this.foo = foo;
+      this.bar = bar;
+    }
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProviderTest.java
index 5f66550..5391d42 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProviderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpClientProviderTest.java
@@ -22,6 +22,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Scopes;
+import java.time.Duration;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.junit.Before;
 import org.junit.Test;
@@ -32,7 +33,7 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class HttpClientProviderTest {
-  private static final int TIME_INTERVAL = 1000;
+  private static final Duration TIME_INTERVAL = Duration.ofMillis(1000);
   private static final String EMPTY = "";
 
   @Mock(answer = Answers.RETURNS_DEEP_STUBS)
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
index 1edcd43..4d874dd 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/HttpSessionTest.java
@@ -30,6 +30,7 @@
 import com.github.tomakehurst.wiremock.stubbing.Scenario;
 import com.google.gson.Gson;
 import java.net.SocketTimeoutException;
+import java.time.Duration;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -38,8 +39,8 @@
 public class HttpSessionTest {
 
   private static final int MAX_TRIES = 3;
-  private static final int RETRY_INTERVAL = 250;
-  private static final int TIMEOUT = 500;
+  private static final Duration RETRY_INTERVAL = Duration.ofMillis(250);
+  private static final Duration TIMEOUT = Duration.ofMillis(500);
   private static final int ERROR = 500;
   private static final int NO_CONTENT = 204;
   private static final int NOT_FOUND = 404;
@@ -141,24 +142,24 @@
             .inScenario(RETRY_AT_DELAY)
             .whenScenarioStateIs(Scenario.STARTED)
             .willSetStateTo(REQUEST_MADE)
-            .willReturn(aResponse().withFixedDelay(TIMEOUT)));
+            .willReturn(aResponse().withFixedDelay((int) TIMEOUT.toMillis())));
     wireMockRule.givenThat(
         post(urlEqualTo(ENDPOINT))
             .inScenario(RETRY_AT_DELAY)
             .whenScenarioStateIs(REQUEST_MADE)
             .willSetStateTo(SECOND_TRY)
-            .willReturn(aResponse().withFixedDelay(TIMEOUT)));
+            .willReturn(aResponse().withFixedDelay((int) TIMEOUT.toMillis())));
     wireMockRule.givenThat(
         post(urlEqualTo(ENDPOINT))
             .inScenario(RETRY_AT_DELAY)
             .whenScenarioStateIs(SECOND_TRY)
             .willSetStateTo(THIRD_TRY)
-            .willReturn(aResponse().withFixedDelay(TIMEOUT)));
+            .willReturn(aResponse().withFixedDelay((int) TIMEOUT.toMillis())));
     wireMockRule.givenThat(
         post(urlEqualTo(ENDPOINT))
             .inScenario(RETRY_AT_DELAY)
             .whenScenarioStateIs(THIRD_TRY)
-            .willReturn(aResponse().withFixedDelay(TIMEOUT)));
+            .willReturn(aResponse().withFixedDelay((int) TIMEOUT.toMillis())));
 
     httpSession.post(uri);
   }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
index 7908669..8a2745e 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
@@ -33,9 +33,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gson.Gson;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Set;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
@@ -105,9 +107,13 @@
     httpSessionMock = mock(HttpSession.class);
     configMock = mock(Configuration.class, Answers.RETURNS_DEEP_STUBS);
     when(configMock.http().maxTries()).thenReturn(3);
-    when(configMock.http().retryInterval()).thenReturn(10);
+    when(configMock.http().retryInterval()).thenReturn(Duration.ofMillis(10));
+    when(configMock.http().threadPoolSize()).thenReturn(2);
     peersMock = mock(Provider.class);
     when(peersMock.get()).thenReturn(ImmutableSet.of(new PeerInfo(URL)));
+    WorkQueue workQueue = mock(WorkQueue.class);
+    when(workQueue.createQueue(configMock.http().threadPoolSize(), "RestForwarderScheduler"))
+        .thenReturn(Executors.newScheduledThreadPool(2));
     forwarder =
         new RestForwarder(
             httpSessionMock,
@@ -115,7 +121,7 @@
             configMock,
             peersMock,
             gson, // TODO: Create provider
-            new RestForwarderScheduler(Executors.newScheduledThreadPool(1)));
+            new FailsafeExecutorProvider(configMock).get());
   }
 
   @Test
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImplTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImplTest.java
index 38f71e5..2cf3fad 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImplTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/ChangeCheckerImplTest.java
@@ -19,7 +19,7 @@
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -38,7 +38,7 @@
 public class ChangeCheckerImplTest {
 
   @Mock private GitRepositoryManager gitRepoMgr;
-  @Mock private CommentsUtil commentsUtil;
+  @Mock private DraftCommentsReader draftCommentsReader;
   @Mock private ChangeFinder changeFinder;
   @Mock private OneOffRequestContext oneOffReqCtx;
   @Mock private ChangeNotes testChangeNotes;
@@ -53,7 +53,8 @@
   @Before
   public void setUp() {
     changeChecker =
-        new ChangeCheckerImpl(gitRepoMgr, commentsUtil, changeFinder, oneOffReqCtx, changeId);
+        new ChangeCheckerImpl(
+            gitRepoMgr, draftCommentsReader, changeFinder, oneOffReqCtx, changeId);
   }
 
   @Test
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandlerTest.java
index 7ed5f55..a8aa4a9 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandlerTest.java
@@ -14,13 +14,10 @@
 
 package com.ericsson.gerrit.plugins.highavailability.index;
 
-import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -29,56 +26,27 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Context;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.DeleteChangeTask;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.IndexAccountTask;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.IndexChangeTask;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.IndexGroupTask;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventHandler.IndexTask;
-import com.ericsson.gerrit.plugins.highavailability.index.IndexEventLocks.VoidFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import java.util.Collection;
-import java.util.List;
 import java.util.Optional;
-import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CyclicBarrier;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.Semaphore;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
-import java.util.function.Supplier;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
-import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
 @RunWith(MockitoJUnitRunner.class)
-@Ignore
 public class IndexEventHandlerTest {
-  private static final String PLUGIN_NAME = "high-availability";
   private static final String PROJECT_NAME = "test/project";
   private static final int CHANGE_ID = 1;
   private static final int ACCOUNT_ID = 2;
   private static final String UUID = "3";
-  private static final String OTHER_UUID = "4";
-  private static final Integer INDEX_WAIT_TIMEOUT_MS = 5;
-  private static final int MAX_TEST_PARALLELISM = 4;
-  private static final String EXECUTOR_THREAD_NAME = "EXECUTOR_THREAD";
 
   private IndexEventHandler indexEventHandler;
   @Mock private Forwarder forwarder;
@@ -87,14 +55,8 @@
   private Change.Id changeId;
   private Account.Id accountId;
   private AccountGroup.UUID accountGroupUUID;
-  private ScheduledExecutorService executor = new CurrentThreadScheduledExecutorService();
-  private ScheduledExecutorService batchExecutor = new CurrentThreadScheduledExecutorService();
-  private ScheduledExecutorService testExecutor =
-      Executors.newScheduledThreadPool(MAX_TEST_PARALLELISM);
   @Mock private RequestContext mockCtx;
   @Mock private Configuration configuration;
-  private IndexEventLocks idLocks;
-  private Thread executorThread;
 
   private CurrentRequestContext currCtx =
       new CurrentRequestContext(null, null, null) {
@@ -112,14 +74,6 @@
     when(changeCheckerFactoryMock.create(any())).thenReturn(changeCheckerMock);
     when(changeCheckerMock.newIndexEvent()).thenReturn(Optional.of(new IndexEvent()));
 
-    Configuration.Index cfgIndex = mock(Configuration.Index.class);
-    when(configuration.index()).thenReturn(cfgIndex);
-    when(cfgIndex.numStripedLocks()).thenReturn(Configuration.DEFAULT_NUM_STRIPED_LOCKS);
-
-    Configuration.Http http = mock(Configuration.Http.class);
-    when(configuration.http()).thenReturn(http);
-    when(http.maxTries()).thenReturn(Configuration.Http.DEFAULT_MAX_TRIES);
-    when(http.retryInterval()).thenReturn(Configuration.Http.DEFAULT_RETRY_INTERVAL);
     when(forwarder.indexAccount(eq(ACCOUNT_ID), any()))
         .thenReturn(CompletableFuture.completedFuture(true));
     when(forwarder.deleteChangeFromIndex(eq(CHANGE_ID), any()))
@@ -128,38 +82,16 @@
     when(forwarder.indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any()))
         .thenReturn(CompletableFuture.completedFuture(true));
 
-    idLocks = new IndexEventLocks(configuration);
     setUpIndexEventHandler(currCtx);
   }
 
   public void setUpIndexEventHandler(CurrentRequestContext currCtx) throws Exception {
-    setUpIndexEventHandler(currCtx, idLocks, configuration);
-  }
-
-  public void setUpIndexEventHandler(CurrentRequestContext currCtx, IndexEventLocks idLocks)
-      throws Exception {
-    setUpIndexEventHandler(currCtx, idLocks, configuration);
-  }
-
-  public void setUpIndexEventHandler(
-      CurrentRequestContext currCtx, IndexEventLocks idLocks, Configuration configuration)
-      throws Exception {
-    indexEventHandler =
-        new IndexEventHandler(
-            executor,
-            batchExecutor,
-            PLUGIN_NAME,
-            forwarder,
-            changeCheckerFactoryMock,
-            currCtx,
-            configuration,
-            idLocks);
+    indexEventHandler = new IndexEventHandler(forwarder, changeCheckerFactoryMock, currCtx);
   }
 
   @Test
   public void shouldIndexInRemoteOnChangeIndexedEvent() throws Exception {
     indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
     verify(forwarder).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
   }
 
@@ -177,160 +109,6 @@
   }
 
   @Test
-  public void shouldNotIndexChangeWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-
-    verify(forwarder, never()).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
-  }
-
-  @Test
-  public void shouldNotIndexAccountWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onAccountIndexed(accountId.get());
-
-    verify(forwarder, never()).indexAccount(eq(ACCOUNT_ID), any());
-  }
-
-  @Test
-  public void shouldNotDeleteChangeWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onChangeDeleted(changeId.get());
-
-    verify(forwarder, never()).deleteChangeFromIndex(eq(CHANGE_ID), any());
-  }
-
-  @Test
-  public void shouldNotIndexGroupWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onGroupIndexed(accountGroupUUID.get());
-
-    verify(forwarder, never()).indexGroup(eq(UUID), any());
-  }
-
-  @Test
-  public void shouldNotIndexProjectWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onProjectIndexed(PROJECT_NAME);
-
-    verify(forwarder, never()).indexProject(eq(PROJECT_NAME), any());
-  }
-
-  @Test
-  public void shouldRetryIndexChangeWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false, true);
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
-    verify(locks, times(2)).withLock(any(), any(), any());
-    verify(forwarder, times(1)).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
-  }
-
-  @Test
-  public void shouldRetryUpToMaxTriesWhenCannotAcquireLock() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-
-    Configuration cfg = mock(Configuration.class);
-    Configuration.Http httpCfg = mock(Configuration.Http.class);
-    when(httpCfg.maxTries()).thenReturn(10);
-    when(cfg.http()).thenReturn(httpCfg);
-    setUpIndexEventHandler(currCtx, locks, cfg);
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
-    verify(locks, times(11)).withLock(any(), any(), any());
-    verify(forwarder, never()).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
-  }
-
-  @Test
-  public void shouldNotRetryWhenMaxTriesLowerThanOne() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore semaphore = mock(Semaphore.class);
-    when(locks.getSemaphore(anyString())).thenReturn(semaphore);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    when(semaphore.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-
-    Configuration cfg = mock(Configuration.class);
-    Configuration.Http httpCfg = mock(Configuration.Http.class);
-    when(httpCfg.maxTries()).thenReturn(0);
-    when(cfg.http()).thenReturn(httpCfg);
-    setUpIndexEventHandler(currCtx, locks, cfg);
-
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
-    verify(locks, times(1)).withLock(any(), any(), any());
-    verify(forwarder, never()).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
-  }
-
-  @Test
-  public void shouldLockPerIndexEventType() throws Exception {
-    IndexEventLocks locks = mock(IndexEventLocks.class);
-    Semaphore indexChangeLock = mock(Semaphore.class);
-    when(indexChangeLock.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(false);
-    Semaphore accountChangeLock = mock(Semaphore.class);
-    when(accountChangeLock.tryAcquire(Mockito.anyLong(), Mockito.eq(TimeUnit.MILLISECONDS)))
-        .thenReturn(true);
-    when(locks.getSemaphore(eq("change/" + CHANGE_ID))).thenReturn(indexChangeLock);
-    when(locks.getSemaphore(eq("account/" + ACCOUNT_ID))).thenReturn(accountChangeLock);
-    Mockito.doCallRealMethod().when(locks).withLock(any(), any(), any());
-    setUpIndexEventHandler(currCtx, locks);
-
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
-    indexEventHandler.onAccountIndexed(accountId.get());
-    executorThread.join();
-    verify(forwarder, never()).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
-    verify(forwarder).indexAccount(eq(ACCOUNT_ID), any());
-  }
-
-  @Test
   public void shouldReindexInRemoteWhenContextIsMissingButForcedIndexingEnabled() throws Exception {
     ThreadLocalRequestContext threadLocalCtxMock = mock(ThreadLocalRequestContext.class);
     OneOffRequestContext oneOffCtxMock = mock(OneOffRequestContext.class);
@@ -341,21 +119,18 @@
     setUpIndexEventHandler(new CurrentRequestContext(threadLocalCtxMock, cfgMock, oneOffCtxMock));
 
     indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    executorThread.join();
     verify(forwarder).indexChange(eq(PROJECT_NAME), eq(CHANGE_ID), any());
   }
 
   @Test
   public void shouldIndexInRemoteOnAccountIndexedEvent() throws Exception {
     indexEventHandler.onAccountIndexed(accountId.get());
-    executorThread.join();
     verify(forwarder).indexAccount(eq(ACCOUNT_ID), any());
   }
 
   @Test
   public void shouldDeleteFromIndexInRemoteOnChangeDeletedEvent() throws Exception {
     indexEventHandler.onChangeDeleted(changeId.get());
-    executorThread.join();
     verify(forwarder).deleteChangeFromIndex(eq(CHANGE_ID), any());
     verifyNoInteractions(changeCheckerMock); // Deleted changes should not be checked against NoteDb
   }
@@ -363,7 +138,6 @@
   @Test
   public void shouldIndexInRemoteOnGroupIndexedEvent() throws Exception {
     indexEventHandler.onGroupIndexed(accountGroupUUID.get());
-    executorThread.join();
     verify(forwarder).indexGroup(eq(UUID), any());
   }
 
@@ -393,396 +167,4 @@
     Context.unsetForwardedEvent();
     verifyNoInteractions(forwarder);
   }
-
-  @Test
-  public void duplicateChangeEventOfAQueuedEventShouldGetDiscarded() {
-    ScheduledThreadPoolExecutor poolMock = mock(ScheduledThreadPoolExecutor.class);
-    ScheduledThreadPoolExecutor poolBatchMock = mock(ScheduledThreadPoolExecutor.class);
-    indexEventHandler =
-        new IndexEventHandler(
-            poolMock,
-            poolBatchMock,
-            PLUGIN_NAME,
-            forwarder,
-            changeCheckerFactoryMock,
-            currCtx,
-            configuration,
-            idLocks);
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    indexEventHandler.onChangeIndexed(PROJECT_NAME, changeId.get());
-    verify(poolMock, times(1))
-        .execute(indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, null));
-  }
-
-  @Test
-  public void duplicateAccountEventOfAQueuedEventShouldGetDiscarded() {
-    ScheduledThreadPoolExecutor poolMock = mock(ScheduledThreadPoolExecutor.class);
-    ScheduledThreadPoolExecutor poolBatchMock = mock(ScheduledThreadPoolExecutor.class);
-    indexEventHandler =
-        new IndexEventHandler(
-            poolMock,
-            poolBatchMock,
-            PLUGIN_NAME,
-            forwarder,
-            changeCheckerFactoryMock,
-            currCtx,
-            configuration,
-            idLocks);
-    indexEventHandler.onAccountIndexed(accountId.get());
-    indexEventHandler.onAccountIndexed(accountId.get());
-    verify(poolMock, times(1)).execute(indexEventHandler.new IndexAccountTask(ACCOUNT_ID));
-  }
-
-  @Test
-  public void duplicateGroupEventOfAQueuedEventShouldGetDiscarded() {
-    ScheduledThreadPoolExecutor poolMock = mock(ScheduledThreadPoolExecutor.class);
-    ScheduledThreadPoolExecutor poolBatchMock = mock(ScheduledThreadPoolExecutor.class);
-    indexEventHandler =
-        new IndexEventHandler(
-            poolMock,
-            poolBatchMock,
-            PLUGIN_NAME,
-            forwarder,
-            changeCheckerFactoryMock,
-            currCtx,
-            configuration,
-            idLocks);
-    indexEventHandler.onGroupIndexed(accountGroupUUID.get());
-    indexEventHandler.onGroupIndexed(accountGroupUUID.get());
-    verify(poolMock, times(1)).execute(indexEventHandler.new IndexGroupTask(UUID));
-  }
-
-  @Test
-  public void testIndexChangeTaskToString() throws Exception {
-    IndexChangeTask task = indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, null);
-    assertThat(task.toString())
-        .isEqualTo(
-            String.format("[%s] Index change %s in target instance", PLUGIN_NAME, CHANGE_ID));
-  }
-
-  @Test
-  public void testIndexAccountTaskToString() throws Exception {
-    IndexAccountTask task = indexEventHandler.new IndexAccountTask(ACCOUNT_ID);
-    assertThat(task.toString())
-        .isEqualTo(
-            String.format("[%s] Index account %s in target instance", PLUGIN_NAME, ACCOUNT_ID));
-  }
-
-  @Test
-  public void testIndexGroupTaskToString() throws Exception {
-    IndexGroupTask task = indexEventHandler.new IndexGroupTask(UUID);
-    assertThat(task.toString())
-        .isEqualTo(String.format("[%s] Index group %s in target instance", PLUGIN_NAME, UUID));
-  }
-
-  @Test
-  public void testIndexChangeTaskHashCodeAndEquals() {
-    IndexChangeTask task = indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, null);
-
-    IndexChangeTask sameTask = task;
-    assertThat(task.equals(sameTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(sameTask.hashCode());
-
-    IndexChangeTask identicalTask =
-        indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, null);
-    assertThat(task.equals(identicalTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(identicalTask.hashCode());
-
-    assertThat(task.equals(null)).isFalse();
-    assertThat(
-            task.equals(indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID + 1, null)))
-        .isFalse();
-    assertThat(task.hashCode()).isNotEqualTo("test".hashCode());
-
-    IndexChangeTask differentChangeIdTask =
-        indexEventHandler.new IndexChangeTask(PROJECT_NAME, 123, null);
-    assertThat(task.equals(differentChangeIdTask)).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo(differentChangeIdTask.hashCode());
-  }
-
-  @Test
-  public void testDeleteChangeTaskHashCodeAndEquals() {
-    DeleteChangeTask task = indexEventHandler.new DeleteChangeTask(CHANGE_ID, null);
-
-    DeleteChangeTask sameTask = task;
-    assertThat(task.equals(sameTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(sameTask.hashCode());
-
-    DeleteChangeTask identicalTask = indexEventHandler.new DeleteChangeTask(CHANGE_ID, null);
-    assertThat(task.equals(identicalTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(identicalTask.hashCode());
-
-    assertThat(task.equals(null)).isFalse();
-    assertThat(task.equals(indexEventHandler.new DeleteChangeTask(CHANGE_ID + 1, null))).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo("test".hashCode());
-
-    DeleteChangeTask differentChangeIdTask = indexEventHandler.new DeleteChangeTask(123, null);
-    assertThat(task.equals(differentChangeIdTask)).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo(differentChangeIdTask.hashCode());
-  }
-
-  @Test
-  public void testIndexAccountTaskHashCodeAndEquals() {
-    IndexAccountTask task = indexEventHandler.new IndexAccountTask(ACCOUNT_ID);
-
-    IndexAccountTask sameTask = task;
-    assertThat(task.equals(sameTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(sameTask.hashCode());
-
-    IndexAccountTask identicalTask = indexEventHandler.new IndexAccountTask(ACCOUNT_ID);
-    assertThat(task.equals(identicalTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(identicalTask.hashCode());
-
-    assertThat(task.equals(null)).isFalse();
-    assertThat(task.equals(indexEventHandler.new IndexAccountTask(ACCOUNT_ID + 1))).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo("test".hashCode());
-
-    IndexAccountTask differentAccountIdTask = indexEventHandler.new IndexAccountTask(123);
-    assertThat(task.equals(differentAccountIdTask)).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo(differentAccountIdTask.hashCode());
-  }
-
-  @Test
-  public void testIndexGroupTaskHashCodeAndEquals() {
-    IndexGroupTask task = indexEventHandler.new IndexGroupTask(UUID);
-
-    IndexGroupTask sameTask = task;
-    assertThat(task.equals(sameTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(sameTask.hashCode());
-
-    IndexGroupTask identicalTask = indexEventHandler.new IndexGroupTask(UUID);
-    assertThat(task.equals(identicalTask)).isTrue();
-    assertThat(task.hashCode()).isEqualTo(identicalTask.hashCode());
-
-    assertThat(task.equals(null)).isFalse();
-    assertThat(task.equals(indexEventHandler.new IndexGroupTask(OTHER_UUID))).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo("test".hashCode());
-
-    IndexGroupTask differentGroupIdTask = indexEventHandler.new IndexGroupTask("123");
-    assertThat(task.equals(differentGroupIdTask)).isFalse();
-    assertThat(task.hashCode()).isNotEqualTo(differentGroupIdTask.hashCode());
-  }
-
-  class TestTask<T> implements Runnable {
-    private IndexTask task;
-    private CyclicBarrier testBarrier;
-    private Supplier<T> successFunc;
-    private VoidFunction failureFunc;
-    private CompletableFuture<T> future;
-
-    public TestTask(
-        IndexTask task,
-        CyclicBarrier testBarrier,
-        Supplier<T> successFunc,
-        VoidFunction failureFunc) {
-      this.task = task;
-      this.testBarrier = testBarrier;
-      this.successFunc = successFunc;
-      this.failureFunc = failureFunc;
-      this.future = new CompletableFuture<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public void run() {
-      try {
-        testBarrier.await();
-        idLocks
-            .withLock(
-                task,
-                () ->
-                    runLater(
-                        INDEX_WAIT_TIMEOUT_MS * 2,
-                        () -> CompletableFuture.completedFuture(successFunc.get())),
-                failureFunc)
-            .whenComplete(
-                (v, t) -> {
-                  if (t == null) {
-                    future.complete((T) v);
-                  } else {
-                    future.completeExceptionally(t);
-                  }
-                });
-      } catch (Throwable t) {
-        future = new CompletableFuture<>();
-        future.completeExceptionally(t);
-      }
-    }
-
-    public void join() {
-      try {
-        future.join();
-      } catch (Exception e) {
-      }
-    }
-
-    private CompletableFuture<T> runLater(
-        long scheduledTimeMsec, Supplier<CompletableFuture<T>> supplier) {
-      CompletableFuture<T> resFuture = new CompletableFuture<>();
-      testExecutor.schedule(
-          () -> {
-            try {
-              return supplier
-                  .get()
-                  .whenComplete(
-                      (v, t) -> {
-                        if (t == null) {
-                          resFuture.complete(v);
-                        }
-                        resFuture.completeExceptionally(t);
-                      });
-            } catch (Throwable t) {
-              return resFuture.completeExceptionally(t);
-            }
-          },
-          scheduledTimeMsec,
-          TimeUnit.MILLISECONDS);
-      return resFuture;
-    }
-  }
-
-  @Test
-  public void indexLocksShouldBlockConcurrentIndexChange() throws Exception {
-    IndexChangeTask indexTask1 =
-        indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, new IndexEvent());
-    IndexChangeTask indexTask2 =
-        indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, new IndexEvent());
-    testIsolationOfCuncurrentIndexTasks(indexTask1, indexTask2);
-  }
-
-  @Test
-  public void indexLocksShouldBlockConcurrentIndexAndDeleteChange() throws Exception {
-    IndexChangeTask indexTask =
-        indexEventHandler.new IndexChangeTask(PROJECT_NAME, CHANGE_ID, new IndexEvent());
-    DeleteChangeTask deleteTask =
-        indexEventHandler.new DeleteChangeTask(CHANGE_ID, new IndexEvent());
-    testIsolationOfCuncurrentIndexTasks(indexTask, deleteTask);
-  }
-
-  private void testIsolationOfCuncurrentIndexTasks(IndexTask indexTask1, IndexTask indexTask2)
-      throws Exception {
-    AtomicInteger changeIndexedCount = new AtomicInteger();
-    AtomicInteger lockFailedCounts = new AtomicInteger();
-    CyclicBarrier changeThreadsSync = new CyclicBarrier(2);
-
-    TestTask<Integer> task1 =
-        new TestTask<>(
-            indexTask1,
-            changeThreadsSync,
-            () -> changeIndexedCount.incrementAndGet(),
-            () -> lockFailedCounts.incrementAndGet());
-    TestTask<Integer> task2 =
-        new TestTask<>(
-            indexTask2,
-            changeThreadsSync,
-            () -> changeIndexedCount.incrementAndGet(),
-            () -> lockFailedCounts.incrementAndGet());
-
-    new Thread(task1).start();
-    new Thread(task2).start();
-    task1.join();
-    task2.join();
-
-    /* Both assertions needs to be true, the order doesn't really matter:
-     * - Only one of the two tasks should succeed
-     * - Only one of the two tasks should fail to acquire the lock
-     */
-    assertThat(changeIndexedCount.get()).isEqualTo(1);
-    assertThat(lockFailedCounts.get()).isEqualTo(1);
-  }
-
-  private class CurrentThreadScheduledExecutorService implements ScheduledExecutorService {
-    @Override
-    public void shutdown() {}
-
-    @Override
-    public List<Runnable> shutdownNow() {
-      return null;
-    }
-
-    @Override
-    public boolean isShutdown() {
-      return false;
-    }
-
-    @Override
-    public boolean isTerminated() {
-      return false;
-    }
-
-    @Override
-    public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
-      return false;
-    }
-
-    @Override
-    public <T> Future<T> submit(Callable<T> task) {
-      return null;
-    }
-
-    @Override
-    public <T> Future<T> submit(Runnable task, T result) {
-      return null;
-    }
-
-    @Override
-    public Future<?> submit(Runnable task) {
-      return null;
-    }
-
-    @Override
-    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
-        throws InterruptedException {
-      return null;
-    }
-
-    @Override
-    public <T> List<Future<T>> invokeAll(
-        Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
-        throws InterruptedException {
-      return null;
-    }
-
-    @Override
-    public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
-        throws InterruptedException, ExecutionException {
-      return null;
-    }
-
-    @Override
-    public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
-        throws InterruptedException, ExecutionException, TimeoutException {
-      return null;
-    }
-
-    @Override
-    public void execute(Runnable command) {
-      executorThread = new Thread(command);
-      executorThread.setName(EXECUTOR_THREAD_NAME);
-      executorThread.start();
-    }
-
-    @Override
-    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
-      command.run();
-      return null;
-    }
-
-    @Override
-    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) {
-      return null;
-    }
-
-    @Override
-    public ScheduledFuture<?> scheduleAtFixedRate(
-        Runnable command, long initialDelay, long period, TimeUnit unit) {
-      return null;
-    }
-
-    @Override
-    public ScheduledFuture<?> scheduleWithFixedDelay(
-        Runnable command, long initialDelay, long delay, TimeUnit unit) {
-      return null;
-    }
-  }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProviderTest.java
deleted file mode 100644
index abab0b9..0000000
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/index/IndexExecutorProviderTest.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2015 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.index;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import com.ericsson.gerrit.plugins.highavailability.Configuration;
-import com.google.gerrit.server.git.WorkQueue;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Answers;
-import org.mockito.Mock;
-import org.mockito.junit.MockitoJUnitRunner;
-
-@RunWith(MockitoJUnitRunner.class)
-public class IndexExecutorProviderTest {
-  @Mock private ScheduledThreadPoolExecutor executorMock;
-  private IndexExecutorProvider indexExecutorProvider;
-
-  @Before
-  public void setUp() throws Exception {
-    executorMock = mock(ScheduledThreadPoolExecutor.class);
-    WorkQueue workQueueMock = mock(WorkQueue.class);
-    when(workQueueMock.createQueue(4, "Forward-Index-Event")).thenReturn(executorMock);
-    Configuration configMock = mock(Configuration.class, Answers.RETURNS_DEEP_STUBS);
-    when(configMock.index().threadPoolSize()).thenReturn(4);
-    indexExecutorProvider = new IndexExecutorProvider(workQueueMock, configMock);
-  }
-
-  @Test
-  public void shouldReturnExecutor() throws Exception {
-    assertThat(indexExecutorProvider.get()).isEqualTo(executorMock);
-  }
-
-  @Test
-  public void testStop() throws Exception {
-    indexExecutorProvider.start();
-    assertThat(indexExecutorProvider.get()).isEqualTo(executorMock);
-    indexExecutorProvider.stop();
-    verify(executorMock).shutdown();
-    assertThat(indexExecutorProvider.get()).isNull();
-  }
-}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsKubernetesPeerInfoProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsKubernetesPeerInfoProviderTest.java
index 0dd27e1..fc8c29a 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsKubernetesPeerInfoProviderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsKubernetesPeerInfoProviderTest.java
@@ -55,9 +55,9 @@
   private Configuration pluginConfigurationMock;
 
   @Mock private InetAddressFinder finder;
-  private JGroupsPeerInfoProvider jGroupsPeerInfoProvider;
-  private JGroupsPeerInfoProvider jGroupsPeerInfoProvider2;
-  @Mock private MyUrlProvider myUrlProviderTest;
+  private JGroupsPeerInfoProvider firstJGroupsPeerInfoProvider;
+  private JGroupsPeerInfoProvider secondJGroupsPeerInfoProvider;
+  @Mock private MyUrlProvider myUrlProvider;
 
   @Rule public WireMockRule kubeApiMock = new WireMockRule(options().port(48443));
 
@@ -93,12 +93,15 @@
     } else {
       when(finder.findAddress()).thenReturn(Optional.of(Inet4Address.getByName("127.0.0.1")));
     }
-    jGroupsPeerInfoProvider =
+    JChannelProvider channelProvider = new JChannelProvider(pluginConfigurationMock);
+    firstJGroupsPeerInfoProvider =
         Mockito.spy(
-            new JGroupsPeerInfoProvider(pluginConfigurationMock, finder, myUrlProviderTest));
-    jGroupsPeerInfoProvider2 =
+            new JGroupsPeerInfoProvider(
+                pluginConfigurationMock, finder, myUrlProvider, channelProvider.get()));
+    secondJGroupsPeerInfoProvider =
         Mockito.spy(
-            new JGroupsPeerInfoProvider(pluginConfigurationMock, finder, myUrlProviderTest));
+            new JGroupsPeerInfoProvider(
+                pluginConfigurationMock, finder, myUrlProvider, channelProvider.get()));
 
     StringBuilder kubeApiUrlBuilder = new StringBuilder();
     kubeApiUrlBuilder.append("/api/v1/namespaces/");
@@ -116,16 +119,16 @@
                 aResponse()
                     .withJsonBody(new ObjectMapper().readTree(respJson))
                     .withStatus(HttpStatus.SC_OK)));
-    jGroupsPeerInfoProvider.connect();
+    firstJGroupsPeerInfoProvider.connect();
     verify(getRequestedFor(urlEqualTo(kubeApiUrl)));
-    jGroupsPeerInfoProvider2.connect();
+    secondJGroupsPeerInfoProvider.connect();
 
-    verify(jGroupsPeerInfoProvider, timeout(10000)).receive(any(Message.class));
+    verify(firstJGroupsPeerInfoProvider, timeout(10000)).receive(any(Message.class));
 
-    assertThat(jGroupsPeerInfoProvider.get().isEmpty()).isFalse();
-    assertThat(jGroupsPeerInfoProvider.get().size()).isEqualTo(1);
+    assertThat(firstJGroupsPeerInfoProvider.get()).isNotEmpty();
+    assertThat(firstJGroupsPeerInfoProvider.get()).hasSize(1);
 
-    assertThat(jGroupsPeerInfoProvider2.get().isEmpty()).isFalse();
-    assertThat(jGroupsPeerInfoProvider2.get().size()).isEqualTo(1);
+    assertThat(secondJGroupsPeerInfoProvider.get()).isNotEmpty();
+    assertThat(secondJGroupsPeerInfoProvider.get()).hasSize(1);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProviderTest.java
index 1110fc3..fd203e0 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProviderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/JGroupsPeerInfoProviderTest.java
@@ -54,8 +54,9 @@
   @Before
   public void setUp() throws Exception {
     finder = new InetAddressFinder(pluginConfigurationMock);
+    JChannel channel = new JChannelProvider(pluginConfigurationMock).get();
     jGroupsPeerInfoProvider =
-        new JGroupsPeerInfoProvider(pluginConfigurationMock, finder, myUrlProviderTest);
+        new JGroupsPeerInfoProvider(pluginConfigurationMock, finder, myUrlProviderTest, channel);
     peerInfo = Optional.of(new PeerInfo("test message"));
     channel.setName("testChannel");
   }
@@ -95,14 +96,6 @@
     jGroupsPeerInfoProvider.viewAccepted(view);
   }
 
-  @Test(expected = NullPointerException.class)
-  public void testViewAcceptedWithExceptionThrown() throws Exception {
-    when(view.getMembers()).thenReturn(members);
-    when(view.size()).thenReturn(2);
-    when(members.size()).thenReturn(2);
-    jGroupsPeerInfoProvider.viewAccepted(view);
-  }
-
   @Test
   public void testViewAcceptedWhenPeerAddressIsNotNullAndIsNotMemberOfView() {
     when(view.getMembers()).thenReturn(members);
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProviderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProviderTest.java
index 1a27de0..f60d52a 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProviderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/peers/jgroups/MyUrlProviderTest.java
@@ -23,13 +23,13 @@
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.ProvisionException;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
-import wiremock.com.google.common.collect.Lists;
 
 @RunWith(MockitoJUnitRunner.class)
 public class MyUrlProviderTest {
@@ -84,7 +84,7 @@
 
   @Test
   public void testGetJGroupsMyUrlFromListenUrlWhenMultipleListenUrlsSpecified() throws Exception {
-    gerritServerConfig.setStringList(HTTPD, null, LISTEN_URL, Lists.newArrayList("a", "b"));
+    gerritServerConfig.setStringList(HTTPD, null, LISTEN_URL, List.of("a", "b"));
     ProvisionException thrown = assertThrows(ProvisionException.class, () -> getMyUrlProvider());
     assertThat(thrown).hasMessageThat().contains("exactly 1 value configured; found 2");
   }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java
index 0a8fc92..19c9a51 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java
@@ -28,6 +28,7 @@
 import com.ericsson.gerrit.plugins.highavailability.websession.file.FileBasedWebSessionCacheCleaner.CleanupTask;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Provider;
+import java.time.Duration;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -41,7 +42,7 @@
 @RunWith(MockitoJUnitRunner.class)
 public class FileBasedWebSessionCacheCleanerTest {
 
-  private static long CLEANUP_INTERVAL = 5000;
+  private static Duration CLEANUP_INTERVAL = Duration.ofSeconds(5);
   private static String SOME_PLUGIN_NAME = "somePluginName";
 
   @Mock private ScheduledThreadPoolExecutor executorMock;
@@ -89,7 +90,10 @@
     cleaner.start();
     verify(executorMock, times(1))
         .scheduleAtFixedRate(
-            isA(CleanupTask.class), eq(1000l), eq(CLEANUP_INTERVAL), eq(TimeUnit.MILLISECONDS));
+            isA(CleanupTask.class),
+            eq(1000l),
+            eq(CLEANUP_INTERVAL.toMillis()),
+            eq(TimeUnit.MILLISECONDS));
   }
 
   @Test