Merge branch 'stable-3.10' into stable-3.11

* stable-3.10:
  De-register deleted repositories from the JGit RepositoryCache

Change-Id: I5e4e300e22a6f11b7d7fa4178f6eb586d954738b
diff --git a/BUILD b/BUILD
index 9bb0445..3735cb3 100644
--- a/BUILD
+++ b/BUILD
@@ -20,10 +20,11 @@
     ],
     resources = glob(["src/main/resources/**/*"]),
     deps = [
-      "@jgroups//jar",
-      "@jgroups-kubernetes//jar",
-      "@failsafe//jar",
-      ":global-refdb-neverlink",
+        ":global-refdb-neverlink",
+        "@auto-value//jar",
+        "@failsafe//jar",
+        "@jgroups-kubernetes//jar",
+        "@jgroups//jar",
     ],
 )
 
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 55f7375..8d7c86a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Configuration.java
@@ -66,6 +66,7 @@
 
   private final Main main;
   private final AutoReindex autoReindex;
+  private final IndexSync indexSync;
   private final PeerInfo peerInfo;
   private final JGroups jgroups;
   private final JGroupsKubernetes jgroupsKubernetes;
@@ -98,6 +99,7 @@
   public Configuration(Config cfg, SitePaths site) {
     main = new Main(site, cfg);
     autoReindex = new AutoReindex(cfg);
+    indexSync = new IndexSync(cfg);
     peerInfo = new PeerInfo(cfg);
     switch (peerInfo.strategy()) {
       case STATIC:
@@ -141,6 +143,10 @@
     return autoReindex;
   }
 
+  public IndexSync indexSync() {
+    return indexSync;
+  }
+
   public PeerInfo peerInfo() {
     return peerInfo;
   }
@@ -276,6 +282,60 @@
     }
   }
 
+  public static class IndexSync {
+
+    static final String INDEX_SYNC_SECTION = "indexSync";
+    static final String ENABLED = "enabled";
+    static final String DELAY = "delay";
+    static final String PERIOD = "period";
+    static final String INITIAL_SYNC_AGE = "initialSyncAge";
+    static final String SYNC_AGE = "syncAge";
+
+    static final boolean DEFAULT_SYNC_INDEX = false;
+    static final Duration DEFAULT_DELAY = Duration.ofSeconds(0);
+    static final Duration DEFAULT_PERIOD = Duration.ofSeconds(2);
+    static final String DEFAULT_INITIAL_SYNC_AGE = "1hour";
+    static final String DEFAULT_SYNC_AGE = "1minute";
+
+    private final boolean enabled;
+    private final Duration delay;
+    private final Duration period;
+    private final String initialSyncAge;
+    private final String syncAge;
+
+    public IndexSync(Config cfg) {
+      enabled = cfg.getBoolean(INDEX_SYNC_SECTION, ENABLED, DEFAULT_SYNC_INDEX);
+      delay = getDuration(cfg, INDEX_SYNC_SECTION, DELAY, DEFAULT_DELAY);
+      period = getDuration(cfg, INDEX_SYNC_SECTION, PERIOD, DEFAULT_PERIOD);
+
+      String v = cfg.getString(INDEX_SYNC_SECTION, "", INITIAL_SYNC_AGE);
+      initialSyncAge = v != null ? v : DEFAULT_INITIAL_SYNC_AGE;
+
+      v = cfg.getString(INDEX_SYNC_SECTION, "", SYNC_AGE);
+      syncAge = v != null ? v : DEFAULT_SYNC_AGE;
+    }
+
+    public boolean enabled() {
+      return enabled;
+    }
+
+    public Duration delay() {
+      return delay;
+    }
+
+    public Duration period() {
+      return period;
+    }
+
+    public String initialSyncAge() {
+      return initialSyncAge;
+    }
+
+    public String syncAge() {
+      return syncAge;
+    }
+  }
+
   public static class PeerInfo {
     static final PeerInfoStrategy DEFAULT_PEER_INFO_STRATEGY = PeerInfoStrategy.STATIC;
     static final String STRATEGY_KEY = "strategy";
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 d9d5c50..4611790 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/Module.java
@@ -21,6 +21,7 @@
 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.indexsync.IndexSyncModule;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfoModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.Inject;
@@ -63,6 +64,9 @@
     }
     if (config.index().synchronize()) {
       install(new IndexModule());
+      if (config.indexSync().enabled()) {
+        install(new IndexSyncModule());
+      }
     }
     if (config.autoReindex().enabled()) {
       install(new AutoReindexModule());
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/ValidationModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/ValidationModule.java
index 74db7d2..d0699b1 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/ValidationModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/ValidationModule.java
@@ -21,6 +21,7 @@
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDatabaseWrapper;
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbBatchRefUpdate;
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbConfiguration;
+import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbExceptionHook;
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbGitRepositoryManager;
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbRefDatabase;
 import com.gerritforge.gerrit.globalrefdb.validation.SharedRefDbRefUpdate;
@@ -30,6 +31,8 @@
 import com.gerritforge.gerrit.globalrefdb.validation.dfsrefdb.DefaultSharedRefEnforcement;
 import com.gerritforge.gerrit.globalrefdb.validation.dfsrefdb.SharedRefEnforcement;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
@@ -66,5 +69,7 @@
           .to(CustomSharedRefEnforcementByProject.class)
           .in(Scopes.SINGLETON);
     }
+
+    DynamicSet.bind(binder(), ExceptionHook.class).to(SharedRefDbExceptionHook.class);
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandler.java
index 414e795..9f7124e 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandler.java
@@ -17,7 +17,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventDispatcher;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -42,11 +41,13 @@
    *
    * @param event The event to dispatch
    */
-  public void dispatch(Event event) throws PermissionBackendException {
+  public void dispatch(Event event) {
     try {
       Context.setForwardedEvent(true);
       log.atFine().log("dispatching event %s", event.getType());
       dispatcher.postEvent(event);
+    } catch (Exception e) {
+      log.atSevere().withCause(e).log("Unable to re-trigger event");
     } finally {
       Context.unsetForwardedEvent();
     }
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 ac102ff..a787d3a 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
@@ -90,13 +90,13 @@
           return true;
         }
 
-        log.atWarning().log(
+        log.atFine().log(
             "Change %s seems too old compared to the event timestamp (event-Ts=%s >> change-Ts=%s)",
             id, indexEvent, checker);
         return false;
       }
 
-      log.atWarning().log(
+      log.atFine().log(
           "Change %s not present yet in local Git repository (event=%s)", id, indexEvent);
       return false;
 
@@ -113,7 +113,7 @@
 
   private void reindex(ChangeNotes notes) {
     notes.reload();
-    indexer.index(notes);
+    indexer.reindexIfStale(notes.getProjectName(), notes.getChangeId());
   }
 
   @Override
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
index 54096ac..73df3eb 100644
--- 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
@@ -26,7 +26,6 @@
 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;
@@ -108,13 +107,7 @@
 
       } 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;
-        }
+        eventHandler.dispatch(event);
 
       } else if (cmd instanceof AddToProjectList) {
         String projectName = ((AddToProjectList) cmd).getProjectName();
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
index 37f0f20..e2e3302 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServlet.java
@@ -24,7 +24,6 @@
 import com.google.common.net.MediaType;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventGson;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -53,10 +52,10 @@
         sendError(rsp, SC_UNSUPPORTED_MEDIA_TYPE, "Expecting " + JSON_UTF_8 + " content type");
         return;
       }
-      forwardedEventHandler.dispatch(getEventFromRequest(req));
+      Event event = getEventFromRequest(req);
       rsp.setStatus(SC_NO_CONTENT);
-    } catch (IOException | PermissionBackendException e) {
-      log.atSevere().withCause(e).log("Unable to re-trigger event");
+      forwardedEventHandler.dispatch(event);
+    } catch (IOException e) {
       sendError(rsp, SC_BAD_REQUEST, e.getMessage());
     }
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/QueryChangesUpdatedSinceServlet.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/QueryChangesUpdatedSinceServlet.java
new file mode 100644
index 0000000..4ebf175
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/QueryChangesUpdatedSinceServlet.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class QueryChangesUpdatedSinceServlet extends AbstractRestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  Gson gson = new Gson();
+
+  private ChangeQueryBuilder changeQueryBuilder;
+  private final Provider<ChangeQueryProcessor> queryProcessorProvider;
+
+  @Inject
+  QueryChangesUpdatedSinceServlet(
+      ChangeQueryBuilder changeQueryBuilder,
+      Provider<ChangeQueryProcessor> queryProcessorProvider) {
+    this.changeQueryBuilder = changeQueryBuilder;
+    this.queryProcessorProvider = queryProcessorProvider;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
+      throws ServletException, IOException {
+    try {
+      String age = req.getPathInfo().substring(1);
+      ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
+      queryProcessor.enforceVisibility(false);
+      queryProcessor.setNoLimit(true);
+      // TODO: prevent too large age, because of the noLimit option
+      Predicate<ChangeData> predicate = Predicate.not(changeQueryBuilder.age(age));
+      QueryResult<ChangeData> result = queryProcessor.query(predicate);
+      ImmutableList<ChangeData> cds = result.entities();
+      ArrayList<String> response = new ArrayList<>(cds.size());
+      for (ChangeData cd : cds) {
+        response.add(String.format("%s~%s", cd.project().get(), cd.getId().get()));
+      }
+
+      String json = gson.toJson(response);
+      rsp.setStatus(SC_OK);
+      rsp.setContentType("application/json");
+      rsp.setCharacterEncoding("UTF-8");
+      PrintWriter out = rsp.getWriter();
+      out.print(json);
+      out.print("\n");
+      out.flush();
+    } catch (IllegalArgumentException e) {
+      rsp.setStatus(SC_BAD_REQUEST);
+    } catch (QueryParseException e) {
+      throw new ServletException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
index 4d3de37..ffe3afc 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderServletModule.java
@@ -27,5 +27,7 @@
     serve("/event/*").with(EventRestApiServlet.class);
     serve("/cache/project_list/*").with(ProjectListApiServlet.class);
     serve("/cache/*").with(CacheRestApiServlet.class);
+
+    serve("/query/changes.updated.since/*").with(QueryChangesUpdatedSinceServlet.class);
   }
 }
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 9236399..76475b0 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
@@ -17,9 +17,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RefNames;
-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;
@@ -28,7 +26,6 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Ref;
@@ -37,7 +34,6 @@
 public class ChangeCheckerImpl implements ChangeChecker {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
   private final GitRepositoryManager gitRepoMgr;
-  private final DraftCommentsReader draftCommentsReader;
   private final OneOffRequestContext oneOffReqCtx;
   private final String changeId;
   private final ChangeFinder changeFinder;
@@ -51,13 +47,11 @@
   @Inject
   public ChangeCheckerImpl(
       GitRepositoryManager gitRepoMgr,
-      DraftCommentsReader draftCommentsReader,
       ChangeFinder changeFinder,
       OneOffRequestContext oneOffReqCtx,
       @Assisted String changeId) {
     this.changeFinder = changeFinder;
     this.gitRepoMgr = gitRepoMgr;
-    this.draftCommentsReader = draftCommentsReader;
     this.oneOffReqCtx = oneOffReqCtx;
     this.changeId = changeId;
   }
@@ -162,7 +156,7 @@
   }
 
   private Optional<Long> computeLastChangeTs() {
-    return getChangeNotes().map(this::getTsFromChangeAndDraftComments);
+    return getChangeNotes().map(this::getTsFromChange);
   }
 
   private String getMetaSha(Repository repo) throws IOException {
@@ -175,14 +169,8 @@
     return ref.getTarget().getObjectId().getName();
   }
 
-  private long getTsFromChangeAndDraftComments(ChangeNotes notes) {
+  private long getTsFromChange(ChangeNotes notes) {
     Change change = notes.getChange();
-    Timestamp changeTs = Timestamp.from(change.getLastUpdatedOn());
-    for (HumanComment comment :
-        draftCommentsReader.getDraftsByChangeForAllAuthors(changeNotes.get())) {
-      Timestamp commentTs = comment.writtenOn;
-      changeTs = commentTs.after(changeTs) ? commentTs : changeTs;
-    }
-    return changeTs.getTime() / 1000;
+    return change.getLastUpdatedOn().toEpochMilli() / 1000;
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncModule.java
new file mode 100644
index 0000000..8d6b282
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncModule.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.indexsync;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.internal.UniqueAnnotations;
+
+public class IndexSyncModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    // NOTE: indexSync.enabled is handled in the plugins main Module
+    // When not enabled, then this module is not installed
+    bind(LifecycleListener.class)
+        .annotatedWith(UniqueAnnotations.create())
+        .to(IndexSyncScheduler.class);
+    bind(QueryChangesResponseHandler.class);
+    factory(IndexSyncRunner.Factory.class);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java
new file mode 100644
index 0000000..92834bd
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncRunner.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.indexsync;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import dev.failsafe.function.CheckedSupplier;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+
+public class IndexSyncRunner implements CheckedSupplier<Boolean> {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  interface Factory {
+    IndexSyncRunner create(String age);
+  }
+
+  private final Provider<Set<PeerInfo>> peerInfoProvider;
+  private final CloseableHttpClient httpClient;
+  private final String pluginRelativePath;
+  private final QueryChangesResponseHandler queryChangesResponseHandler;
+  private final ChangeIndexer.Factory changeIndexerFactory;
+  private final ListeningExecutorService executor;
+  private final ChangeIndexCollection changeIndexes;
+  private final ChangeQueryBuilder queryBuilder;
+  private final Provider<ChangeQueryProcessor> queryProcessorProvider;
+  private final ChangeFinder changeFinder;
+  private final String age;
+
+  @AssistedInject
+  IndexSyncRunner(
+      Provider<Set<PeerInfo>> peerInfoProvider,
+      CloseableHttpClient httpClient,
+      @PluginName String pluginName,
+      QueryChangesResponseHandler queryChangesResponseHandler,
+      ChangeIndexer.Factory changeIndexerFactory,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ChangeIndexCollection changeIndexes,
+      ChangeQueryBuilder queryBuilder,
+      Provider<ChangeQueryProcessor> queryProcessorProvider,
+      ChangeFinder changeFinder,
+      @Assisted String age) {
+    this.peerInfoProvider = peerInfoProvider;
+    this.httpClient = httpClient;
+    this.pluginRelativePath = Joiner.on("/").join("plugins", pluginName);
+    this.queryChangesResponseHandler = queryChangesResponseHandler;
+    this.changeIndexerFactory = changeIndexerFactory;
+    this.executor = executor;
+    this.changeIndexes = changeIndexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessorProvider = queryProcessorProvider;
+    this.changeFinder = changeFinder;
+    this.age = age;
+  }
+
+  @Override
+  public Boolean get() {
+    log.atFine().log("Starting indexSync");
+    Set<PeerInfo> peers = peerInfoProvider.get();
+    if (peers.size() == 0) {
+      return false;
+    }
+
+    ChangeIndexer indexer = changeIndexerFactory.create(executor, changeIndexes, false);
+    // NOTE: this loop will stop as soon as the initial sync is performed from one peer
+    for (PeerInfo peer : peers) {
+      if (syncFrom(peer, indexer)) {
+        log.atFine().log("Finished indexSync");
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private boolean syncFrom(PeerInfo peer, ChangeIndexer indexer) {
+    log.atFine().log("Syncing index with %s", peer.getDirectUrl());
+    String peerUrl = peer.getDirectUrl();
+    String uri =
+        Joiner.on("/").join(peerUrl, pluginRelativePath, "query/changes.updated.since", age);
+    HttpGet queryRequest = new HttpGet(uri);
+    List<String> ids;
+    try {
+      log.atFine().log("Executing %s", queryRequest);
+      ids = httpClient.execute(queryRequest, queryChangesResponseHandler);
+    } catch (IOException e) {
+      log.atSevere().withCause(e).log("Error while querying changes from %s", uri);
+      return false;
+    }
+
+    try {
+      List<ListenableFuture<Boolean>> indexingTasks = new ArrayList<>(ids.size());
+      for (String id : ids) {
+        indexingTasks.add(indexAsync(id, indexer));
+      }
+      Futures.allAsList(indexingTasks).get();
+    } catch (InterruptedException | ExecutionException e) {
+      log.atSevere().withCause(e).log("Error while reindexing %s", ids);
+      return false;
+    }
+
+    syncChangeDeletions(ids, indexer);
+
+    return true;
+  }
+
+  private ListenableFuture<Boolean> indexAsync(String id, ChangeIndexer indexer) {
+    List<String> fields = Splitter.on("~").splitToList(id);
+    if (fields.size() != 2) {
+      throw new IllegalArgumentException(String.format("Unexpected change ID format %s", id));
+    }
+    Project.NameKey projectName = Project.nameKey(fields.get(0));
+    Integer changeNumber = Ints.tryParse(fields.get(1));
+    if (changeNumber == null) {
+      throw new IllegalArgumentException(
+          String.format("Unexpected change number format %s", fields.get(1)));
+    }
+    log.atInfo().log("Scheduling async reindex of: %s", id);
+    return indexer.asyncReindexIfStale(projectName, Change.id(changeNumber));
+  }
+
+  private void syncChangeDeletions(List<String> theirChanges, ChangeIndexer indexer) {
+    Set<String> ourChanges = queryLocalIndex();
+    for (String d : Sets.difference(ourChanges, ImmutableSet.copyOf(theirChanges))) {
+      deleteIfMissingInNoteDb(d, indexer);
+    }
+  }
+
+  private Set<String> queryLocalIndex() {
+    ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
+    queryProcessor.enforceVisibility(false);
+    queryProcessor.setNoLimit(true);
+    Predicate<ChangeData> predicate = Predicate.not(queryBuilder.age(age));
+    QueryResult<ChangeData> result;
+    try {
+      result = queryProcessor.query(predicate);
+    } catch (QueryParseException e) {
+      throw new RuntimeException(e);
+    }
+
+    ImmutableList<ChangeData> cds = result.entities();
+    return cds.stream()
+        .map(cd -> cd.project().get() + "~" + cd.getId().get())
+        .collect(Collectors.toSet());
+  }
+
+  private void deleteIfMissingInNoteDb(String id, ChangeIndexer indexer) {
+    List<ChangeNotes> changeNotes = changeFinder.find(id);
+    if (changeNotes.isEmpty()) {
+      List<String> parts = Splitter.on("~").splitToList(id);
+      Project.NameKey project = Project.nameKey(parts.get(0));
+      Change.Id changeId = Change.id(Integer.parseInt(parts.get(1)));
+      log.atInfo().log("Change %s present in index but not in noteDb. Deleting from index", id);
+      indexer.deleteAsync(project, changeId);
+    }
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncScheduler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncScheduler.java
new file mode 100644
index 0000000..9d647cf
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/IndexSyncScheduler.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.indexsync;
+
+import com.ericsson.gerrit.plugins.highavailability.Configuration;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.failsafe.Failsafe;
+import dev.failsafe.FailsafeExecutor;
+import dev.failsafe.RetryPolicy;
+import dev.failsafe.event.ExecutionCompletedEvent;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class IndexSyncScheduler implements LifecycleListener {
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  private final WorkQueue workQueue;
+  private final IndexSyncRunner.Factory indexSyncRunnerFactory;
+  private final Configuration.IndexSync indexSync;
+
+  private ScheduledExecutorService executor;
+
+  @Inject
+  IndexSyncScheduler(
+      WorkQueue workQueue, IndexSyncRunner.Factory indexSyncRunnerFactory, Configuration cfg) {
+    this.workQueue = workQueue;
+    this.indexSyncRunnerFactory = indexSyncRunnerFactory;
+    this.indexSync = cfg.indexSync();
+  }
+
+  @Override
+  public void start() {
+    executor = workQueue.createQueue(4, "IndexSyncRunner");
+
+    scheduleInitialSync();
+    schedulePeriodicSync();
+  }
+
+  private void scheduleInitialSync() {
+    // Initial sync has to be run once but we may need to retry it until the other
+    // peer becomes reachable
+    // Therefore, we use failsafe to define and execute retries
+    RetryPolicy<Boolean> retryPolicy =
+        RetryPolicy.<Boolean>builder()
+            .withMaxAttempts(12 * 60) // 5s * 12 * 60 = 1 hour
+            .withDelay(Duration.ofSeconds(5))
+            .onRetriesExceeded(e -> logRetriesExceeded(e))
+            .handleResult(false)
+            .build();
+    FailsafeExecutor<Boolean> failsafeExecutor = Failsafe.with(retryPolicy).with(executor);
+
+    IndexSyncRunner sync = indexSyncRunnerFactory.create(indexSync.initialSyncAge());
+    failsafeExecutor.getAsync(sync);
+  }
+
+  private void schedulePeriodicSync() {
+    // Periodic sync runs at fixed rate and we don't need failsafe for retries
+    IndexSyncRunner sync = indexSyncRunnerFactory.create(indexSync.syncAge());
+    executor.scheduleAtFixedRate(
+        () -> sync.get(),
+        indexSync.delay().getSeconds(),
+        indexSync.period().getSeconds(),
+        TimeUnit.SECONDS);
+  }
+
+  private void logRetriesExceeded(ExecutionCompletedEvent<Boolean> e) {
+    log.atSevere().log("Retries for initial index sync exceeded %s", e);
+  }
+
+  @Override
+  public void stop() {
+    executor.shutdown();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/QueryChangesResponseHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/QueryChangesResponseHandler.java
new file mode 100644
index 0000000..66e957a
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/indexsync/QueryChangesResponseHandler.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.ericsson.gerrit.plugins.highavailability.indexsync;
+
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.StatusLine;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.HttpResponseException;
+import org.apache.http.client.ResponseHandler;
+import org.apache.http.util.EntityUtils;
+
+@Singleton
+public class QueryChangesResponseHandler implements ResponseHandler<List<String>> {
+
+  private final Gson gson = new Gson();
+
+  @Override
+  public List<String> handleResponse(HttpResponse rsp) throws ClientProtocolException, IOException {
+    StatusLine status = rsp.getStatusLine();
+    if (rsp.getStatusLine().getStatusCode() != SC_OK) {
+      throw new HttpResponseException(status.getStatusCode(), "Query failed");
+    }
+    HttpEntity entity = rsp.getEntity();
+    if (entity == null) {
+      return List.of();
+    }
+    String body = EntityUtils.toString(entity);
+    return gson.fromJson(body, new TypeToken<List<String>>() {}.getType());
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
index 74310ae..111a175 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/peers/PeerInfo.java
@@ -14,6 +14,8 @@
 
 package com.ericsson.gerrit.plugins.highavailability.peers;
 
+import java.util.Objects;
+
 public class PeerInfo {
 
   private final String directUrl;
@@ -25,4 +27,14 @@
   public String getDirectUrl() {
     return directUrl;
   }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(directUrl);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof PeerInfo) && Objects.equals(directUrl, ((PeerInfo) o).directUrl);
+  }
 }
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 776a0a4..63f65d6 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
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.peers.jgroups;
 
+import autovalue.shaded.com.google.common.collect.ImmutableMap;
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
 import com.google.common.annotations.VisibleForTesting;
@@ -24,8 +25,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.net.InetAddress;
+import java.util.Iterator;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import org.jgroups.Address;
 import org.jgroups.JChannel;
 import org.jgroups.Message;
@@ -39,9 +43,8 @@
  * each gerrit server publishes its url to all cluster members (publishes it to all channels).
  *
  * <p>This provider maintains a list of all members which joined the jgroups cluster. This may be
- * more than two. But will always pick the first node which sent its url as the peer to be returned
- * by {@link #get()}. It will continue to return that node until that node leaves the jgroups
- * cluster.
+ * more than two. The set of urls of all peers is returned by {@link #get()}. If a node leaves the
+ * jgroups cluster it's removed from this set.
  */
 @Singleton
 public class JGroupsPeerInfoProvider
@@ -53,8 +56,7 @@
   private final String myUrl;
 
   private JChannel channel;
-  private Optional<PeerInfo> peerInfo = Optional.empty();
-  private Address peerAddress;
+  private Map<Address, PeerInfo> peers = new ConcurrentHashMap<>();
 
   @Inject
   JGroupsPeerInfoProvider(
@@ -70,34 +72,28 @@
 
   @Override
   public void receive(Message msg) {
-    synchronized (this) {
-      if (peerAddress != null) {
-        return;
-      }
-      peerAddress = msg.getSrc();
-      String url = (String) msg.getObject();
-      peerInfo = Optional.of(new PeerInfo(url));
-      log.atInfo().log("receive(): Set new peerInfo: %s", url);
+    String url = (String) msg.getObject();
+    if (url == null) {
+      return;
+    }
+    Address addr = msg.getSrc();
+    PeerInfo old = peers.put(addr, new PeerInfo(url));
+    if (old == null) {
+      log.atInfo().log("receive(): Add new peerInfo: %s", url);
+    } else {
+      log.atInfo().log("receive(): Update peerInfo: from %s to %s", old.getDirectUrl(), url);
     }
   }
 
   @Override
   public void viewAccepted(View view) {
     log.atInfo().log("viewAccepted(view: %s) called", view);
-    synchronized (this) {
-      if (view.getMembers().size() > 2) {
-        log.atWarning().log(
-            "%d members joined the jgroups cluster %s (%s). "
-                + " Only two members are supported. Members: %s",
-            view.getMembers().size(),
-            jgroupsConfig.clusterName(),
-            channel.getName(),
-            view.getMembers());
-      }
-      if (peerAddress != null && !view.getMembers().contains(peerAddress)) {
-        log.atInfo().log("viewAccepted(): removed peerInfo");
-        peerAddress = null;
-        peerInfo = Optional.empty();
+    Iterator<Map.Entry<Address, PeerInfo>> it = peers.entrySet().iterator();
+    while (it.hasNext()) {
+      Map.Entry<Address, PeerInfo> e = it.next();
+      if (!view.getMembers().contains(e.getKey())) {
+        log.atInfo().log("viewAccepted(): removed peerInfo %s", e.getValue().getDirectUrl());
+        it.remove();
       }
     }
     if (view.size() > 1) {
@@ -144,14 +140,9 @@
     this.channel = channel;
   }
 
-  @VisibleForTesting
-  void setPeerInfo(Optional<PeerInfo> peerInfo) {
-    this.peerInfo = peerInfo;
-  }
-
   @Override
   public Set<PeerInfo> get() {
-    return peerInfo.isPresent() ? ImmutableSet.of(peerInfo.get()) : ImmutableSet.of();
+    return ImmutableSet.copyOf(peers.values());
   }
 
   @Override
@@ -167,17 +158,19 @@
           channel.getName(), jgroupsConfig.clusterName());
       channel.close();
     }
-    peerInfo = Optional.empty();
-    peerAddress = null;
+    peers.clear();
   }
 
   @VisibleForTesting
-  Address getPeerAddress() {
-    return peerAddress;
+  Map<Address, PeerInfo> getPeers() {
+    return ImmutableMap.copyOf(peers);
   }
 
   @VisibleForTesting
-  void setPeerAddress(Address peerAddress) {
-    this.peerAddress = peerAddress;
+  void addPeer(Address address, PeerInfo info) {
+    if (address == null) {
+      return;
+    }
+    this.peers.put(address, info);
   }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index d89f7b3..c7a3e35 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -105,6 +105,39 @@
     Delay is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
     When not specified, polling of conditional reindexing is disabled.
 
+**NOTE:** The indexSync feature exposes a REST endpoint that can be used to discover project names.
+Admins are advised to restrict access to the REST endpoints exposed by this plugin.
+
+```indexSync.enabled```
+:   When indexSync is enabled, the primary servers will synchronize indexes with the intention to
+    self-heal any missed reindexing event.
+
+```indexSync.delay```
+:   If enabled, index sync will start running after this initial delay.
+    Delay is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default is zero: run immediately.
+
+```indexSync.period```
+    Period between two index sync executions. If any execution of this task takes longer than
+    this period, then subsequent executions may start late, but will not concurrently execute.
+    Delay is expressed in Gerrit time values as in [websession.cleanupInterval](#websessioncleanupInterval).
+    When not specified, the default is `2 seconds`.
+
+```indexSync.initialSyncAge```
+    This options defines the max age of changes in the other peer for which local index shall
+    be synchronized on the initial run of the index sync task. The age defined here is usualy
+    larger than the `syncAge` in order to accommodate max foreseen downtime of a server during
+    restarts.
+    The age is express in the format of the `age:` change query parameter.
+    When not specified, the default is `1hour`.
+
+```indexSync.syncAge```
+    This option defines the max age of changes in the other peer for this local index shall be
+    synchronized on each run, except for the initial run.
+    The age is express in the format of the `age:` change query parameter.
+    When not specified, the default is `5minutes`.
+
+
 ```peerInfo.strategy```
 :   Strategy to find other peers. Supported strategies are `static` or `jgroups`.
     Defaults to `jgroups`.
diff --git a/src/test/README.md b/src/test/README.md
index fd59f64..16e4df9 100644
--- a/src/test/README.md
+++ b/src/test/README.md
@@ -1,24 +1,17 @@
 # Gerrit high-availability docker setup example
 
-The Docker Compose project in the docker directory contains a simple test 
+The Docker Compose project in the docker directory contains a simple test
 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 (make sure you set the
-`platform` attribute in the docker-compose.yaml file if you're not 
-in an amd64 arch).
+The project can be built using docker compose.
 
 To build the Docker VMs:
+
 ```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
+  docker compose build
 ```
 
 ### Building the Docker VMs using a non-default user id
@@ -28,7 +21,7 @@
 Then, run the following:
 ```
   $ export GERRIT_UID=$(id -u)
-  $ docker-compose build --build-arg GERRIT_UID
+  $ docker compose build --build-arg GERRIT_UID
 ```
 
 Above, exporting that UID is optional and will be 1000 by default.
@@ -51,10 +44,11 @@
 Use the 'up' target to startup the Docker Compose VMs.
 
 ```
-  $ docker-compose up -d
+  $ 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).
@@ -67,11 +61,11 @@
 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 
+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 
+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.
@@ -86,7 +80,7 @@
 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
+## Gerrit high-availability local setup example
 
  1. Init gerrit instances with high-availability plugin installed:
     1. Optionally, set http port of those instance to 8081 and 8082.
@@ -137,5 +131,5 @@
 target.
 
 ```
-  $ docker-compose down
+  $ docker compose down
 ```
diff --git a/src/test/docker/docker-compose.elasticsearch.yaml b/src/test/docker/docker-compose.elasticsearch.yaml
new file mode 100644
index 0000000..31f6269
--- /dev/null
+++ b/src/test/docker/docker-compose.elasticsearch.yaml
@@ -0,0 +1,157 @@
+version: '3'
+
+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:
+      gerrit-net:
+        ipv4_address: 192.168.1.5
+    volumes:
+      - nfs-server-volume:/var/gerrit/git
+
+  zookeeper-refdb:
+    image: zookeeper
+    ports:
+      - "2181:2181"
+    networks:
+      - gerrit-net
+    healthcheck:
+      test: ["CMD-SHELL", "./bin/zkServer.sh", "status"] # required, otherwise the gerrit service will fail to start with a "connection refused" error
+      interval: 1s
+      timeout: 1m
+      retries: 10
+
+  elasticsearch:
+    image: docker.elastic.co/elasticsearch/elasticsearch:8.9.2
+    container_name: elasticsearch
+    environment:
+      - cluster.name=elasticsearch-cluster
+      - node.name=elasticsearch
+      - cluster.initial_master_nodes=elasticsearch
+      - bootstrap.memory_lock=true
+      - xpack.security.enabled=false
+      - xpack.security.http.ssl.enabled=false
+      - ELASTIC_PASSWORD=os_Secret1234
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+      nofile:
+        soft: 65536
+        hard: 65536
+    volumes:
+      - elasticsearch-data:/usr/share/elasticsearch/data
+    ports:
+      - 9200:9200
+      - 9600:9600
+    networks:
+      - gerrit-net
+    healthcheck:
+      test: ["CMD-SHELL", "curl -k -u elastic:os_Secret1234 --silent --fail http://localhost:9200/_cluster/health"]
+      interval: 10s
+      timeout: 1m
+      retries: 10
+      start_period: 10s
+      start_interval: 5s
+
+  gerrit-01:
+    build: gerrit
+    privileged: true
+    depends_on:
+      elasticsearch:
+        condition: service_healthy
+      nfs-server:
+        condition: service_healthy
+      zookeeper-refdb:
+        condition: service_healthy
+    ports:
+      - "8081:8080"
+      - "29411:29418"
+    networks:
+      - gerrit-net
+    volumes:
+      - /dev/urandom:/dev/random
+      - git-volume:/var/gerrit/git
+      - shareddir:/var/gerrit/shareddir
+      - ./etc/gerrit_es.config:/var/gerrit/etc/gerrit.config.orig
+      - ./etc/high-availability.gerrit-01.config:/var/gerrit/etc/high-availability.config.orig
+      - ./etc/zookeeper-refdb.config:/var/gerrit/etc/zookeeper-refdb.config.orig
+    environment:
+      - HOSTNAME=localhost
+      - INDEX_TYPE=ELASTICSEARCH
+
+  gerrit-02:
+    build: gerrit
+    privileged: true
+    ports:
+      - "8082:8080"
+      - "29412:29418"
+    networks:
+      - gerrit-net
+    depends_on:
+      gerrit-01:
+        condition: service_started
+      nfs-server:
+        condition: service_healthy
+    volumes:
+      - /dev/urandom:/dev/random
+      - git-volume:/var/gerrit/git
+      - shareddir:/var/gerrit/shareddir
+      - ./etc/gerrit_es.config:/var/gerrit/etc/gerrit.config.orig
+      - ./etc/high-availability.gerrit-02.config:/var/gerrit/etc/high-availability.config.orig
+      - ./etc/zookeeper-refdb.config:/var/gerrit/etc/zookeeper-refdb.config.orig
+    environment:
+      - HOSTNAME=localhost
+      - INDEX_TYPE=ELASTICSEARCH
+      - WAIT_FOR=gerrit-01:8080
+
+  haproxy:
+    build: haproxy
+    ports:
+      - "80:80"
+      - "29418:29418"
+    networks:
+      - gerrit-net
+    volumes_from:
+      - syslog-sidecar
+    depends_on:
+      - syslog-sidecar
+      - gerrit-01
+      - gerrit-02
+
+  syslog-sidecar:
+    build: docker-syslog-ng-stdout
+    networks:
+      - gerrit-net
+
+networks:
+  gerrit-net:
+    ipam:
+      driver: default
+      config:
+        - subnet: 192.168.1.0/24
+
+volumes:
+  shareddir:
+  nfs-server-volume:
+  elasticsearch-data:
+  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-compose.yaml b/src/test/docker/docker-compose.yaml
index 8524963..2e7981d 100644
--- a/src/test/docker/docker-compose.yaml
+++ b/src/test/docker/docker-compose.yaml
@@ -2,7 +2,6 @@
 
   nfs-server:
     build: nfs
-#    platform: linux/arm64/v8 # uncomment for Apple Silicon arch
     privileged: true
     container_name: nfs-server
     environment:
@@ -16,27 +15,43 @@
     ports:
       - 2049:2049
     networks:
-      nfs-server-bridge:
+      gerrit-net:
         ipv4_address: 192.168.1.5
     volumes:
       - nfs-server-volume:/var/gerrit/git
+
+  zookeeper-refdb:
+    image: zookeeper
+    ports:
+      - "2181:2181"
+    networks:
+      - gerrit-net
+    healthcheck:
+      test: ["CMD-SHELL", "./bin/zkServer.sh", "status"] # required, otherwise the gerrit service will fail to start with a "connection refused" error
+      interval: 1s
+      timeout: 1m
+      retries: 10
+
   gerrit-01:
     build: gerrit
     privileged: true
     depends_on:
       nfs-server:
         condition: service_healthy
+      zookeeper-refdb:
+        condition: service_healthy
     ports:
       - "8081:8080"
       - "29411:29418"
     networks:
-      nfs-server-bridge: null
+      - gerrit-net
     volumes:
       - /dev/urandom:/dev/random
       - 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
+      - ./etc/zookeeper-refdb.config:/var/gerrit/etc/zookeeper-refdb.config.orig
     environment:
       - HOSTNAME=localhost
 
@@ -47,7 +62,7 @@
       - "8082:8080"
       - "29412:29418"
     networks:
-      nfs-server-bridge: null
+      - gerrit-net
     depends_on:
       gerrit-01:
         condition: service_started
@@ -59,6 +74,7 @@
       - 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
+      - ./etc/zookeeper-refdb.config:/var/gerrit/etc/zookeeper-refdb.config.orig
     environment:
       - HOSTNAME=localhost
       - WAIT_FOR=gerrit-01:8080
@@ -69,9 +85,9 @@
       - "80:80"
       - "29418:29418"
     networks:
-      nfs-server-bridge: null
-    volumes:
-      - syslog-sidecar:/syslog-sidecar
+      - gerrit-net
+    volumes_from:
+      - syslog-sidecar
     depends_on:
       - syslog-sidecar
       - gerrit-01
@@ -80,10 +96,10 @@
   syslog-sidecar:
     build: docker-syslog-ng-stdout
     networks:
-      nfs-server-bridge: null
+      - gerrit-net
 
 networks:
-  nfs-server-bridge:
+  gerrit-net:
     ipam:
       driver: default
       config:
diff --git a/src/test/docker/etc/gerrit.config b/src/test/docker/etc/gerrit.config
index 90a4057..408316a 100644
--- a/src/test/docker/etc/gerrit.config
+++ b/src/test/docker/etc/gerrit.config
@@ -2,11 +2,14 @@
 	basePath = git
 	canonicalWebUrl = http://gerrit:8080/
 	serverId = f7696647-8efd-41b1-bd60-d321bc071ea9
+	installDbModule = com.ericsson.gerrit.plugins.highavailability.ValidationModule
+	installModule = com.gerritforge.gerrit.globalrefdb.validation.LibModule
 [index]
 	type = LUCENE
 [auth]
 	type = DEVELOPMENT_BECOME_ANY_ACCOUNT
 	cookiedomain = localhost
+	cookieHttpOnly = false
 [sendemail]
 	smtpServer = localhost
 [sshd]
diff --git a/src/test/docker/etc/gerrit_es.config b/src/test/docker/etc/gerrit_es.config
new file mode 100644
index 0000000..669a3af
--- /dev/null
+++ b/src/test/docker/etc/gerrit_es.config
@@ -0,0 +1,30 @@
+[gerrit]
+	basePath = git
+	canonicalWebUrl = http://gerrit:8080/
+	serverId = f7696647-8efd-41b1-bd60-d321bc071ea9
+	installDbModule = com.ericsson.gerrit.plugins.highavailability.ValidationModule
+	installModule = com.gerritforge.gerrit.globalrefdb.validation.LibModule
+	installIndexModule = com.google.gerrit.elasticsearch.PrimaryElasticIndexModule
+[elasticsearch]
+	server = http://elasticsearch:9200
+	username = elastic
+	password = os_Secret1234
+[auth]
+	type = DEVELOPMENT_BECOME_ANY_ACCOUNT
+	cookiedomain = localhost
+	cookieHttpOnly = false
+[sendemail]
+	smtpServer = localhost
+[sshd]
+	listenAddress = *:29418
+[httpd]
+	listenUrl = proxy-http://*:8080/
+	requestLog = true
+[cache]
+	directory = cache
+[container]
+	user = gerrit
+[download]
+	scheme = http
+	scheme = ssh
+	scheme = anon_http
diff --git a/src/test/docker/etc/high-availability.gerrit-01.config b/src/test/docker/etc/high-availability.gerrit-01.config
index a21f05c..d8619c1 100644
--- a/src/test/docker/etc/high-availability.gerrit-01.config
+++ b/src/test/docker/etc/high-availability.gerrit-01.config
@@ -6,3 +6,6 @@
 
 [peerInfo "static"]
   url = http://gerrit-02:8080
+
+[ref-database]
+  enabled = true
diff --git a/src/test/docker/etc/high-availability.gerrit-02.config b/src/test/docker/etc/high-availability.gerrit-02.config
index d05c7ec..54cc1f2 100644
--- a/src/test/docker/etc/high-availability.gerrit-02.config
+++ b/src/test/docker/etc/high-availability.gerrit-02.config
@@ -5,4 +5,7 @@
   strategy = static
 
 [peerInfo "static"]
-  url = http://gerrit-01:8080
\ No newline at end of file
+  url = http://gerrit-01:8080
+
+[ref-database]
+  enabled = true
diff --git a/src/test/docker/etc/zookeeper-refdb.config b/src/test/docker/etc/zookeeper-refdb.config
new file mode 100644
index 0000000..d3cdb80
--- /dev/null
+++ b/src/test/docker/etc/zookeeper-refdb.config
@@ -0,0 +1,4 @@
+[ref-database "zookeeper"]
+  connectString = "zookeeper-refdb:2181"
+  rootNode = "gerrit/HA"
+  transactionLockTimeoutMs = 1000
diff --git a/src/test/docker/gerrit/Dockerfile b/src/test/docker/gerrit/Dockerfile
index e70e0ea..aeac272 100644
--- a/src/test/docker/gerrit/Dockerfile
+++ b/src/test/docker/gerrit/Dockerfile
@@ -1,4 +1,4 @@
-FROM almalinux:9.3
+FROM almalinux:9.4
 
 # Install dependencies
 RUN yum -y install \
@@ -10,7 +10,7 @@
     gettext \
     && yum -y clean all
 
-ENV GERRIT_VERSION=3.10
+ENV GERRIT_VERSION master
 
 # Add gerrit user
 RUN adduser -p -m --uid 1000 gerrit --home-dir /home/gerrit
@@ -22,19 +22,32 @@
 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" \
+    "https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-$GERRIT_BRANCH/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" \
+    "https://gerrit-ci.gerritforge.com/view/Plugins-master/job/plugin-javamelody-bazel-master-$GERRIT_BRANCH/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" \
+    "https://gerrit-ci.gerritforge.com/job/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 \
-    "https://gerrit-ci.gerritforge.com/job/module-global-refdb-bazel-stable-$GERRIT_VERSION/lastSuccessfulBuild/artifact/bazel-bin/plugins/global-refdb/global-refdb.jar" \
+
+RUN mkdir -p /var/gerrit/lib && \
+    ln -sf /var/gerrit/plugins/high-availability.jar /var/gerrit/lib/high-availability.jar
+
+ADD --chown=gerrit:gerrit \
+    "https://gerrit-ci.gerritforge.com/job/module-global-refdb-bazel-$GERRIT_BRANCH/lastSuccessfulBuild/artifact/bazel-bin/plugins/global-refdb/global-refdb.jar" \
     /var/gerrit/lib/global-refdb.jar
 
+ADD --chown=gerrit:gerrit \
+    "https://gerrit-ci.gerritforge.com/job/plugin-zookeeper-refdb-bazel-$GERRIT_BRANCH/lastSuccessfulBuild/artifact/bazel-bin/plugins/zookeeper-refdb/zookeeper-refdb.jar" \
+    /var/gerrit/plugins/zookeeper-refdb.jar
+
+ADD --chown=gerrit:gerrit \
+    "https://gerrit-ci.gerritforge.com/view/Plugins-master/job/module-index-elasticsearch-bazel-master/lastSuccessfulBuild/artifact/bazel-bin/plugins/index-elasticsearch/index-elasticsearch.jar" \
+    /var/gerrit_plugins/index-elasticsearch.jar
+
 ADD --chown=gerrit:gerrit ./wait-for-it.sh /bin
 
 # Change user
diff --git a/src/test/docker/gerrit/entrypoint.sh b/src/test/docker/gerrit/entrypoint.sh
index 2d4e387..e6181da 100755
--- a/src/test/docker/gerrit/entrypoint.sh
+++ b/src/test/docker/gerrit/entrypoint.sh
@@ -8,7 +8,15 @@
 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
+sudo -u gerrit cp /var/gerrit/etc/zookeeper-refdb.config.orig /var/gerrit/etc/zookeeper-refdb.config
+sudo -u gerrit git config -f /var/gerrit/etc/healthcheck.config healthcheck.auth.enabled false
+sudo -u gerrit git config -f /var/gerrit/etc/healthcheck.config healthcheck.querychanges.enabled false
+sudo -u gerrit git config -f /var/gerrit/etc/healthcheck.config healthcheck.changesindex.enabled false
 
+if [[ "$INDEX_TYPE" == "ELASTICSEARCH" ]]; then
+  ln -sf /var/gerrit_plugins/index-elasticsearch.jar /var/gerrit/lib/index-elasticsearch.jar
+  ln -sf /var/gerrit_plugins/index-elasticsearch.jar /var/gerrit/plugins/index-elasticsearch.jar
+fi
 
 echo "Init gerrit..."
 sudo -u gerrit java -jar /tmp/gerrit.war init -d /var/gerrit --batch --install-all-plugins
diff --git a/src/test/docker/haproxy/haproxy.cfg b/src/test/docker/haproxy/haproxy.cfg
index e86cdce..07a6746 100644
--- a/src/test/docker/haproxy/haproxy.cfg
+++ b/src/test/docker/haproxy/haproxy.cfg
@@ -45,7 +45,7 @@
     timeout connect 10s
     timeout server 5m
     server gerrit_ssh_01 gerrit-01:29418 check port 8080 inter 10s fall 3 rise 2
-    server gerrit-ssh_02 gerrit-02:29418 check port 8080 inter 10s fall 3 rise 2 backup
+    server gerrit-ssh_02 gerrit-02:29418 check port 8080 inter 10s fall 3 rise 2
 
 backend gerrit_http_nodes
     mode http
@@ -55,7 +55,7 @@
     option httpchk GET /config/server/version HTTP/1.0
     http-check expect status 200
     server gerrit_01 gerrit-01:8080 check
-    server gerrit_02 gerrit-02:8080 check backup
+    server gerrit_02 gerrit-02:8080 check
 
 backend gerrit_http_nodes_balanced
     mode http
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandlerTest.java
index 3e1f8fd..73e3401 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedEventHandlerTest.java
@@ -15,7 +15,6 @@
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
 
@@ -82,9 +81,7 @@
         .postEvent(event);
 
     assertThat(Context.isForwardedEvent()).isFalse();
-    PermissionBackendException thrown =
-        assertThrows(PermissionBackendException.class, () -> handler.dispatch(event));
-    assertThat(thrown).hasMessageThat().isEqualTo("someMessage");
+    handler.dispatch(event);
     assertThat(Context.isForwardedEvent()).isFalse();
 
     verify(dispatcherMock).postEvent(event);
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 be74231..27d5b2d 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
@@ -31,6 +31,7 @@
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl;
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutorProvider;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -59,6 +60,7 @@
 
   @Mock private ChangeIndexer indexerMock;
   @Mock private ChangeNotes changeNotes;
+  @Mock private Project.NameKey projectName;
 
   @Mock(answer = RETURNS_DEEP_STUBS)
   private Configuration configMock;
@@ -87,14 +89,15 @@
   public void changeIsIndexedWhenUpToDate() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_EXISTS, CHANGE_UP_TO_DATE);
     handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
-    verify(indexerMock, times(1)).index(any(ChangeNotes.class));
+    verify(indexerMock, times(1)).reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
   }
 
   @Test
   public void changeIsStillIndexedEvenWhenOutdated() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_EXISTS, CHANGE_OUTDATED);
     handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.of(new IndexEvent())).get(10, SECONDS);
-    verify(indexerMock, atLeast(1)).index(any(ChangeNotes.class));
+    verify(indexerMock, atLeast(1))
+        .reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
   }
 
   @Test
@@ -122,13 +125,13 @@
                   return null;
                 })
         .when(indexerMock)
-        .index(any(ChangeNotes.class));
+        .reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
 
     assertThat(Context.isForwardedEvent()).isFalse();
     handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
     assertThat(Context.isForwardedEvent()).isFalse();
 
-    verify(indexerMock, times(1)).index(any(ChangeNotes.class));
+    verify(indexerMock, times(1)).reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
   }
 
   @Test
@@ -141,7 +144,7 @@
                   throw new IOException("someMessage");
                 })
         .when(indexerMock)
-        .index(any(ChangeNotes.class));
+        .reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
 
     assertThat(Context.isForwardedEvent()).isFalse();
     ExecutionException thrown =
@@ -152,7 +155,7 @@
     assertThat(thrown.getCause()).hasMessageThat().isEqualTo("someMessage");
     assertThat(Context.isForwardedEvent()).isFalse();
 
-    verify(indexerMock, times(1)).index(any(ChangeNotes.class));
+    verify(indexerMock, times(1)).reindexIfStale(any(Project.NameKey.class), any(Change.Id.class));
   }
 
   private void setupChangeAccessRelatedMocks(boolean changeExists, boolean changeIsUpToDate)
@@ -161,7 +164,8 @@
       when(changeCheckerFactoryMock.create(TEST_CHANGE_ID)).thenReturn(changeCheckerPresentMock);
       when(changeCheckerPresentMock.getChangeNotes()).thenReturn(Optional.of(changeNotes));
     }
-
+    when(changeNotes.getChangeId()).thenReturn(id);
+    when(changeNotes.getProjectName()).thenReturn(projectName);
     when(changeCheckerPresentMock.isChangeUpToDate(any())).thenReturn(changeIsUpToDate);
   }
 }
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServletTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServletTest.java
index 3c6b931..7c23e29 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServletTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/EventRestApiServletTest.java
@@ -26,6 +26,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.ForwardedEventHandler;
 import com.google.common.net.MediaType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.events.EventGsonProvider;
 import com.google.gerrit.server.events.EventTypes;
 import com.google.gerrit.server.events.RefEvent;
@@ -41,6 +42,7 @@
 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)
@@ -85,11 +87,15 @@
             + "\"refs/changes/76/669676/2\",\"nodesCount\":1,\"type\":"
             + "\"ref-replication-done\",\"eventCreatedOn\":1451415011}";
     when(requestMock.getReader()).thenReturn(new BufferedReader(new StringReader(event)));
+
+    EventDispatcher dispatcher = Mockito.mock(EventDispatcher.class);
     doThrow(new PermissionBackendException(ERR_MSG))
-        .when(forwardedEventHandlerMock)
-        .dispatch(any(RefReplicationDoneEvent.class));
+        .when(dispatcher)
+        .postEvent(any(RefReplicationDoneEvent.class));
+    ForwardedEventHandler forwardedEventHandler = new ForwardedEventHandler(dispatcher);
+    eventRestApiServlet = new EventRestApiServlet(forwardedEventHandler, gson);
     eventRestApiServlet.doPost(requestMock, responseMock);
-    verify(responseMock).sendError(SC_BAD_REQUEST, ERR_MSG);
+    verify(responseMock).setStatus(SC_NO_CONTENT);
   }
 
   @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 2cf3fad..dfea1df 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,6 @@
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.gerrit.entities.Change;
-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 +37,6 @@
 public class ChangeCheckerImplTest {
 
   @Mock private GitRepositoryManager gitRepoMgr;
-  @Mock private DraftCommentsReader draftCommentsReader;
   @Mock private ChangeFinder changeFinder;
   @Mock private OneOffRequestContext oneOffReqCtx;
   @Mock private ChangeNotes testChangeNotes;
@@ -52,9 +50,7 @@
 
   @Before
   public void setUp() {
-    changeChecker =
-        new ChangeCheckerImpl(
-            gitRepoMgr, draftCommentsReader, changeFinder, oneOffReqCtx, changeId);
+    changeChecker = new ChangeCheckerImpl(gitRepoMgr, changeFinder, oneOffReqCtx, changeId);
   }
 
   @Test
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 fc8c29a..0c53959 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
@@ -87,6 +87,8 @@
     when(pluginConfigurationMock.jgroupsKubernetes().namespace()).thenReturn(namespace);
     when(pluginConfigurationMock.jgroupsKubernetes().labels()).thenReturn(labels);
 
+    when(myUrlProvider.get()).thenReturn("http://127.0.0.1:7800");
+
     NetworkInterface eth0 = NetworkInterface.getByName("eth0");
     if (eth0 != null) {
       when(finder.findAddress()).thenReturn(eth0.inetAddresses().findFirst());
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 fd203e0..a098be6 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
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Answers.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.when;
 
 import com.ericsson.gerrit.plugins.highavailability.Configuration;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
 import java.util.List;
-import java.util.Optional;
 import java.util.Set;
 import org.jgroups.Address;
 import org.jgroups.JChannel;
@@ -43,7 +43,7 @@
 
   private InetAddressFinder finder;
   private JGroupsPeerInfoProvider jGroupsPeerInfoProvider;
-  private Optional<PeerInfo> peerInfo;
+  private PeerInfo peerInfo;
   @Mock private JChannel channel;
   @Mock private MyUrlProvider myUrlProviderTest;
   @Mock private Message message;
@@ -57,7 +57,7 @@
     JChannel channel = new JChannelProvider(pluginConfigurationMock).get();
     jGroupsPeerInfoProvider =
         new JGroupsPeerInfoProvider(pluginConfigurationMock, finder, myUrlProviderTest, channel);
-    peerInfo = Optional.of(new PeerInfo("test message"));
+    peerInfo = new PeerInfo("test message");
     channel.setName("testChannel");
   }
 
@@ -68,7 +68,7 @@
 
     jGroupsPeerInfoProvider.receive(message);
 
-    assertThat(jGroupsPeerInfoProvider.getPeerAddress()).isEqualTo(peerAddress);
+    assertThat(jGroupsPeerInfoProvider.getPeers()).containsKey(peerAddress);
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
     for (PeerInfo testPeerInfo : testPeerInfoSet) {
       assertThat(testPeerInfo.getDirectUrl()).contains("test message");
@@ -78,20 +78,52 @@
 
   @Test
   public void testReceiveWhenPeerAddressIsNotNull() throws Exception {
-    jGroupsPeerInfoProvider.setPeerAddress(new IpAddress("checkAddress.com"));
+    lenient().when(message.getSrc()).thenReturn(peerAddress);
+    when(message.getObject()).thenReturn(null);
 
     jGroupsPeerInfoProvider.receive(message);
 
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
-    assertThat(testPeerInfoSet.isEmpty()).isTrue();
-    assertThat(testPeerInfoSet.size()).isEqualTo(0);
+    assertThat(testPeerInfoSet).isEmpty();
+  }
+
+  @Test
+  public void testReceiveMultiplePeers() throws Exception {
+    IpAddress addr1 = new IpAddress("192.168.1.5:7800");
+    IpAddress addr2 = new IpAddress("192.168.1.6:7800");
+    IpAddress addr3 = new IpAddress("192.168.1.7:7800");
+    PeerInfo peer1 = new PeerInfo("URL1");
+    PeerInfo peer2 = new PeerInfo("URL2");
+    PeerInfo peer3 = new PeerInfo("URL3");
+
+    receive(addr1, peer1);
+    receive(addr2, peer2);
+    receive(addr3, peer3);
+
+    Set<PeerInfo> peers = jGroupsPeerInfoProvider.get();
+    assertThat(peers.size()).isEqualTo(3);
+    assertThat(peers).containsExactly(peer1, peer2, peer3);
+
+    // remove one peer with address ADDR1 from the view
+    List<Address> reducedView = List.of(addr2, addr3);
+    when(view.getMembers()).thenReturn(reducedView);
+    when(view.size()).thenReturn(2);
+    jGroupsPeerInfoProvider.setChannel(channel);
+    jGroupsPeerInfoProvider.viewAccepted(view);
+    peers = jGroupsPeerInfoProvider.get();
+    assertThat(peers.size()).isEqualTo(2);
+    assertThat(peers).containsExactly(peer2, peer3);
+  }
+
+  public void receive(final IpAddress addr, final PeerInfo peer) {
+    when(message.getSrc()).thenReturn(addr);
+    when(message.getObject()).thenReturn(peer.getDirectUrl());
+    jGroupsPeerInfoProvider.receive(message);
   }
 
   @Test(expected = None.class)
   public void testViewAcceptedWithNoExceptionThrown() throws Exception {
-    when(view.getMembers()).thenReturn(members);
     when(view.size()).thenReturn(3);
-    when(members.size()).thenReturn(3);
     jGroupsPeerInfoProvider.setChannel(channel);
     jGroupsPeerInfoProvider.viewAccepted(view);
   }
@@ -100,37 +132,32 @@
   public void testViewAcceptedWhenPeerAddressIsNotNullAndIsNotMemberOfView() {
     when(view.getMembers()).thenReturn(members);
     when(view.size()).thenReturn(2);
-    when(members.size()).thenReturn(2);
     when(members.contains(peerAddress)).thenReturn(false);
-    jGroupsPeerInfoProvider.setPeerAddress(peerAddress);
-    jGroupsPeerInfoProvider.setPeerInfo(peerInfo);
+    jGroupsPeerInfoProvider.addPeer(peerAddress, peerInfo);
     jGroupsPeerInfoProvider.setChannel(channel);
     jGroupsPeerInfoProvider.viewAccepted(view);
 
-    assertThat(jGroupsPeerInfoProvider.getPeerAddress()).isEqualTo(null);
+    assertThat(jGroupsPeerInfoProvider.getPeers()).isEmpty();
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
-    assertThat(testPeerInfoSet.isEmpty()).isTrue();
-    assertThat(testPeerInfoSet.size()).isEqualTo(0);
+    assertThat(testPeerInfoSet).isEmpty();
   }
 
   @Test
-  public void testConnect() throws NoSuchFieldException, IllegalAccessException {
+  public void testConnect() {
     jGroupsPeerInfoProvider.connect();
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
-    assertThat(testPeerInfoSet.isEmpty()).isTrue();
-    assertThat(testPeerInfoSet.size()).isEqualTo(0);
+    assertThat(testPeerInfoSet).isEmpty();
   }
 
   @Test
   public void testGetWhenPeerInfoIsOptionalEmpty() {
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
-    assertThat(testPeerInfoSet.isEmpty()).isTrue();
-    assertThat(testPeerInfoSet.size()).isEqualTo(0);
+    assertThat(testPeerInfoSet).isEmpty();
   }
 
   @Test
   public void testGetWhenPeerInfoIsPresent() {
-    jGroupsPeerInfoProvider.setPeerInfo(peerInfo);
+    jGroupsPeerInfoProvider.addPeer(peerAddress, peerInfo);
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
     for (PeerInfo testPeerInfo : testPeerInfoSet) {
       assertThat(testPeerInfo.getDirectUrl()).contains("test message");
@@ -140,12 +167,10 @@
 
   @Test
   public void testStop() throws Exception {
-    jGroupsPeerInfoProvider.setPeerAddress(peerAddress);
-    jGroupsPeerInfoProvider.setPeerInfo(peerInfo);
+    jGroupsPeerInfoProvider.addPeer(peerAddress, peerInfo);
     jGroupsPeerInfoProvider.stop();
-    assertThat(jGroupsPeerInfoProvider.getPeerAddress()).isEqualTo(null);
+    assertThat(jGroupsPeerInfoProvider.getPeers().isEmpty());
     Set<PeerInfo> testPeerInfoSet = jGroupsPeerInfoProvider.get();
-    assertThat(testPeerInfoSet.isEmpty()).isTrue();
-    assertThat(testPeerInfoSet.size()).isEqualTo(0);
+    assertThat(testPeerInfoSet).isEmpty();
   }
 }