Merge branch 'stable-3.8' into stable-3.10

* stable-3.8:
  synchronizePendingEvents: Fix stuck replaying flag

Release-Notes: skip
Change-Id: I277e4bbfefee4895615976d1b26440d55a1eef58
diff --git a/BUILD b/BUILD
index ee97660..9c209ed 100644
--- a/BUILD
+++ b/BUILD
@@ -14,6 +14,23 @@
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.replication.SshModule",
     ],
     resources = glob(["src/main/resources/**/*"]),
+    deps = [
+        "//plugins/replication:replication-api",
+    ],
+)
+
+gerrit_plugin(
+    name = "replication-api",
+    srcs = glob(
+        ["src/main/java/com/googlesource/gerrit/plugins/replication/api/*.java"],
+    ),
+    dir_name = "replication",
+    manifest_entries = [
+        "Implementation-Title: Replication plugin API",
+        "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/replication",
+        "Gerrit-PluginName: replication-api",
+        "Gerrit-ApiModule: com.googlesource.gerrit.plugins.replication.api.ApiModule",
+    ],
 )
 
 junit_tests(
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
index a0d9624..2c049fc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.nio.file.Path;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -42,10 +43,9 @@
   public AutoReloadConfigDecorator(
       @PluginName String pluginName,
       WorkQueue workQueue,
-      @MainReplicationConfig ReplicationConfig replicationConfig,
       AutoReloadRunnable reloadRunner,
       EventBus eventBus) {
-    this.currentConfig = replicationConfig;
+    this.currentConfig = reloadRunner.getCurrentReplicationConfig();
     this.autoReloadExecutor = workQueue.createQueue(1, pluginName + "_auto-reload-config");
     this.reloadRunner = reloadRunner;
     eventBus.register(this);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnable.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnable.java
index 71f7c67..a752a35 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnable.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnable.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.util.List;
 
 public class AutoReloadRunnable implements Runnable {
@@ -27,14 +28,14 @@
   private final Provider<ObservableQueue> queueObserverProvider;
   private final ConfigParser configParser;
   private ReplicationConfig loadedConfig;
-  private Provider<ReplicationConfig> replicationConfigProvider;
+  private Provider<ReplicationConfigImpl> replicationConfigProvider;
   private String loadedConfigVersion;
   private String lastFailedConfigVersion;
 
   @Inject
   public AutoReloadRunnable(
       ConfigParser configParser,
-      @MainReplicationConfig Provider<ReplicationConfig> replicationConfigProvider,
+      Provider<ReplicationConfigImpl> replicationConfigProvider,
       EventBus eventBus,
       Provider<ObservableQueue> queueObserverProvider) {
     this.replicationConfigProvider = replicationConfigProvider;
@@ -60,6 +61,10 @@
     reload();
   }
 
+  public ReplicationConfig getCurrentReplicationConfig() {
+    return loadedConfig;
+  }
+
   synchronized void reload() {
     String pendingConfigVersion = loadedConfig.getVersion();
     try {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
index 5bae0af..e9409a6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
@@ -19,6 +19,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.util.concurrent.atomic.AtomicReference;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
index 2436fee..00f8baf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.util.Optional;
 import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.URIish;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index 1f01e2a..c186493 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -15,7 +15,7 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
-import static com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig.replaceName;
+import static com.googlesource.gerrit.plugins.replication.ReplicationConfigImpl.replaceName;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.NON_EXISTING;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
@@ -71,8 +71,8 @@
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -127,8 +127,8 @@
   }
 
   public static class QueueInfo {
-    public final Map<URIish, PushOne> pending;
-    public final Map<URIish, PushOne> inFlight;
+    public final ImmutableMap<URIish, PushOne> pending;
+    public final ImmutableMap<URIish, PushOne> inFlight;
 
     public QueueInfo(Map<URIish, PushOne> pending, Map<URIish, PushOne> inFlight) {
       this.pending = ImmutableMap.copyOf(pending);
@@ -409,13 +409,13 @@
       ReplicationState state,
       boolean now,
       boolean fromStorage) {
-    Set<String> refsToSchedule = new HashSet<>();
+    ImmutableSet.Builder<String> toSchedule = ImmutableSet.builder();
     for (String ref : refs) {
       if (!shouldReplicate(project, ref, state)) {
         repLog.atFine().log("Not scheduling replication %s:%s => %s", project, ref, uri);
         continue;
       }
-      refsToSchedule.add(ref);
+      toSchedule.add(ref);
     }
     repLog.atInfo().log("scheduling replication %s:%s => %s", project, refs, uri);
 
@@ -444,11 +444,13 @@
       }
     }
 
+    ImmutableSet<String> refsToSchedule = toSchedule.build();
+    PushOne task;
     synchronized (stateLock) {
-      PushOne task = getPendingPush(uri);
+      task = getPendingPush(uri);
       if (task == null) {
         task = opFactory.create(project, uri);
-        addRefs(task, ImmutableSet.copyOf(refsToSchedule));
+        task.addRefBatch(refsToSchedule);
         task.addState(refsToSchedule, state);
         @SuppressWarnings("unused")
         ScheduledFuture<?> ignored =
@@ -458,7 +460,7 @@
             "scheduled %s:%s => %s to run %s",
             project, refsToSchedule, task, now ? "now" : "after " + config.getDelay() + "s");
       } else {
-        boolean added = addRefs(task, ImmutableSet.copyOf(refsToSchedule));
+        boolean added = task.addRefBatch(refsToSchedule);
         task.addState(refsToSchedule, state);
         String message = "consolidated %s:%s => %s with an existing pending push";
         if (added || !fromStorage) {
@@ -471,6 +473,7 @@
         state.increasePushTaskCount(project.get(), ref);
       }
     }
+    postReplicationScheduledEvent(task, refsToSchedule);
   }
 
   @Nullable
@@ -483,11 +486,13 @@
   }
 
   void pushWasCanceled(PushOne pushOp) {
+    Set<ImmutableSet<String>> notAttemptedRefs = Collections.emptySet();
     synchronized (stateLock) {
       URIish uri = pushOp.getURI();
       pending.remove(uri);
-      pushOp.notifyNotAttempted(pushOp.getRefs());
+      notAttemptedRefs = pushOp.getRefs();
     }
+    pushOp.notifyNotAttempted(notAttemptedRefs);
   }
 
   void scheduleDeleteProject(URIish uri, Project.NameKey project, ProjectDeletionState state) {
@@ -504,12 +509,6 @@
         pool.schedule(updateHeadFactory.create(uri, project, newHead), 0, TimeUnit.SECONDS);
   }
 
-  private boolean addRefs(PushOne e, ImmutableSet<String> refs) {
-    boolean added = e.addRefBatch(refs);
-    postReplicationScheduledEvent(e, refs);
-    return added;
-  }
-
   /**
    * It schedules again a PushOp instance.
    *
@@ -532,6 +531,10 @@
    * @param pushOp The PushOp instance to be scheduled.
    */
   void reschedule(PushOne pushOp, RetryReason reason) {
+    boolean isRescheduled = false;
+    boolean isFailed = false;
+    RemoteRefUpdate.Status failedStatus = null;
+
     synchronized (stateLock) {
       URIish uri = pushOp.getURI();
       PushOne pendingPushOp = getPendingPush(uri);
@@ -587,13 +590,13 @@
           case TRANSPORT_ERROR:
           case REPOSITORY_MISSING:
           default:
-            RemoteRefUpdate.Status status =
+            failedStatus =
                 RetryReason.REPOSITORY_MISSING.equals(reason)
                     ? NON_EXISTING
                     : REJECTED_OTHER_REASON;
-            postReplicationFailedEvent(pushOp, status);
+            isFailed = true;
             if (pushOp.setToRetry()) {
-              postReplicationScheduledEvent(pushOp);
+              isRescheduled = true;
               replicationTasksStorage.get().reset(pushOp);
               @SuppressWarnings("unused")
               ScheduledFuture<?> ignored2 =
@@ -610,6 +613,12 @@
         }
       }
     }
+    if (isFailed) {
+      postReplicationFailedEvent(pushOp, failedStatus);
+    }
+    if (isRescheduled) {
+      postReplicationScheduledEvent(pushOp);
+    }
   }
 
   RunwayStatus requestRunway(PushOne op) {
@@ -659,7 +668,7 @@
     }
 
     // by default push all projects
-    List<String> projects = config.getProjects();
+    ImmutableList<String> projects = config.getProjects();
     if (projects.isEmpty()) {
       return true;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
index 79a0683..8a41945 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -17,7 +17,7 @@
 import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isGerrit;
 import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isGerritHttp;
 import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isSSH;
-import static com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig.replaceName;
+import static com.googlesource.gerrit.plugins.replication.ReplicationConfigImpl.replaceName;
 import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
 import static java.util.stream.Collectors.toList;
 
@@ -35,7 +35,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
@@ -52,7 +53,7 @@
 
   private final Destination.Factory destinationFactory;
   private final Provider<ReplicationQueue> replicationQueue;
-  private volatile List<Destination> destinations;
+  private volatile ImmutableList<Destination> destinations;
   private boolean shuttingDown;
 
   public static class EventQueueNotEmptyException extends Exception {
@@ -258,7 +259,7 @@
     }
   }
 
-  private List<Destination> allDestinations(
+  private ImmutableList<Destination> allDestinations(
       Destination.Factory destinationFactory, List<RemoteConfiguration> remoteConfigurations) {
 
     ImmutableList.Builder<Destination> dest = ImmutableList.builder();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
similarity index 71%
rename from src/main/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfig.java
rename to src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
index b915d0d..4220ddb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
@@ -21,7 +21,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
-import com.google.gerrit.extensions.annotations.PluginData;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -36,30 +35,26 @@
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-public class FanoutReplicationConfig implements ReplicationConfig {
+public class FanoutConfigResource extends FileConfigResource {
+  public static String CONFIG_DIR = "replication";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ReplicationFileBasedConfig replicationConfig;
-  private final Config config;
   private final Path remoteConfigsDirPath;
 
   @Inject
-  public FanoutReplicationConfig(SitePaths site, @PluginData Path pluginDataDir)
-      throws IOException, ConfigInvalidException {
-
-    remoteConfigsDirPath = site.etc_dir.resolve("replication");
-    replicationConfig = new ReplicationFileBasedConfig(site, pluginDataDir);
-    config = replicationConfig.getConfig();
+  FanoutConfigResource(SitePaths site) throws IOException, ConfigInvalidException {
+    super(site);
+    this.remoteConfigsDirPath = site.etc_dir.resolve(CONFIG_DIR);
     removeRemotes(config);
 
     try (Stream<Path> files = Files.list(remoteConfigsDirPath)) {
       files
           .filter(Files::isRegularFile)
-          .filter(FanoutReplicationConfig::isConfig)
-          .map(FanoutReplicationConfig::loadConfig)
+          .filter(FanoutConfigResource::isConfig)
+          .map(FanoutConfigResource::loadConfig)
           .filter(Optional::isPresent)
           .map(Optional::get)
-          .filter(FanoutReplicationConfig::isValid)
+          .filter(FanoutConfigResource::isValid)
           .forEach(cfg -> addRemoteConfig(cfg, config));
     } catch (IllegalStateException e) {
       throw new ConfigInvalidException(e.getMessage());
@@ -122,53 +117,14 @@
   }
 
   @Override
-  public boolean isReplicateAllOnPluginStart() {
-    return replicationConfig.isReplicateAllOnPluginStart();
-  }
-
-  @Override
-  public boolean isDefaultForceUpdate() {
-    return replicationConfig.isDefaultForceUpdate();
-  }
-
-  @Override
-  public int getMaxRefsToLog() {
-    return replicationConfig.getMaxRefsToLog();
-  }
-
-  @Override
-  public int getMaxRefsToShow() {
-    return replicationConfig.getMaxRefsToShow();
-  }
-
-  @Override
-  public Path getEventsDirectory() {
-    return replicationConfig.getEventsDirectory();
-  }
-
-  @Override
-  public int getSshConnectionTimeout() {
-    return replicationConfig.getSshConnectionTimeout();
-  }
-
-  @Override
-  public int getSshCommandTimeout() {
-    return replicationConfig.getSshCommandTimeout();
-  }
-
-  @Override
-  public int getDistributionInterval() {
-    return replicationConfig.getDistributionInterval();
-  }
-
-  @Override
   public String getVersion() {
+    String parentVersion = super.getVersion();
     Hasher hasher = Hashing.murmur3_128().newHasher();
-    hasher.putString(replicationConfig.getVersion(), UTF_8);
+    hasher.putString(parentVersion, UTF_8);
     try (Stream<Path> files = Files.list(remoteConfigsDirPath)) {
       files
           .filter(Files::isRegularFile)
-          .filter(FanoutReplicationConfig::isConfig)
+          .filter(FanoutConfigResource::isConfig)
           .sorted()
           .map(Path::toFile)
           .map(FileSnapshot::save)
@@ -181,12 +137,7 @@
       logger.atSevere().withCause(e).log(
           "Cannot list remote configuration files from %s. Returning replication.config file version",
           remoteConfigsDirPath);
-      return replicationConfig.getVersion();
+      return parentVersion;
     }
   }
-
-  @Override
-  public Config getConfig() {
-    return config;
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
new file mode 100644
index 0000000..baccb83
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
@@ -0,0 +1,60 @@
+// 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.googlesource.gerrit.plugins.replication;
+
+import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+public class FileConfigResource implements ConfigResource {
+  public static final String CONFIG_NAME = "replication.config";
+  protected final FileBasedConfig config;
+
+  @Inject
+  @VisibleForTesting
+  @UsedAt(Project.PLUGIN_PULL_REPLICATION)
+  public FileConfigResource(SitePaths site) {
+    Path cfgPath = site.etc_dir.resolve(CONFIG_NAME);
+    this.config = new FileBasedConfig(cfgPath.toFile(), FS.DETECTED);
+    try {
+      config.load();
+    } catch (ConfigInvalidException e) {
+      repLog.atSevere().withCause(e).log("Config file %s is invalid: %s", cfgPath, e.getMessage());
+    } catch (IOException e) {
+      repLog.atSevere().withCause(e).log("Cannot read %s: %s", cfgPath, e.getMessage());
+    }
+  }
+
+  @Override
+  public Config getConfig() {
+    return config;
+  }
+
+  @Override
+  public String getVersion() {
+    return Long.toString(config.getFile().lastModified());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
index 9264d9b..8f4e0a1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ListCommand.java
@@ -23,7 +23,7 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.util.Collection;
 import java.util.List;
 import org.kohsuke.args4j.Option;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
new file mode 100644
index 0000000..43d90a5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
@@ -0,0 +1,83 @@
+// 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.googlesource.gerrit.plugins.replication;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Suppliers;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import java.util.function.Supplier;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public class MergedConfigResource {
+  @VisibleForTesting
+  @UsedAt(Project.PLUGIN_PULL_REPLICATION)
+  public static MergedConfigResource withBaseOnly(ConfigResource base) {
+    MergedConfigResource mergedConfigResource = new MergedConfigResource();
+    mergedConfigResource.baseConfigProvider = Providers.of(base);
+    return mergedConfigResource;
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Inject private Provider<ConfigResource> baseConfigProvider;
+
+  private final Supplier<ConfigResource> base =
+      Suppliers.memoize(() -> this.baseConfigProvider.get());
+
+  @Inject(optional = true)
+  @Nullable
+  private DynamicItem<ReplicationConfigOverrides> overrides;
+
+  public Config getConfig() {
+    Config config = base.get().getConfig();
+    if (noOverrides()) {
+      return config;
+    }
+
+    String overridesText = overrides.get().getConfig().toText();
+    if (!overridesText.isEmpty()) {
+      try {
+        config.fromText(overridesText);
+      } catch (ConfigInvalidException e) {
+        logger.atWarning().withCause(e).log("Failed to merge replication config overrides");
+      }
+    }
+
+    return config;
+  }
+
+  public String getVersion() {
+    String baseVersion = base.get().getVersion();
+    if (noOverrides()) {
+      return baseVersion;
+    }
+
+    return baseVersion + overrides.get().getVersion();
+  }
+
+  private boolean noOverrides() {
+    return overrides == null || overrides.get() == null;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Nfs.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Nfs.java
deleted file mode 100644
index 09a632d..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Nfs.java
+++ /dev/null
@@ -1,53 +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.googlesource.gerrit.plugins.replication;
-
-import java.io.IOException;
-import java.util.Locale;
-
-/** Some NFS utilities */
-public class Nfs {
-  /**
-   * Determine if a throwable or a cause in its causal chain is a Stale NFS File Handle
-   *
-   * @return a boolean true if the throwable or a cause in its causal chain is a Stale NFS File
-   *     Handle
-   */
-  public static boolean isStaleFileHandleInCausalChain(Throwable throwable) {
-    while (throwable != null) {
-      if (throwable instanceof IOException && isStaleFileHandle((IOException) throwable)) {
-        return true;
-      }
-      throwable = throwable.getCause();
-    }
-    return false;
-  }
-
-  /**
-   * Determine if an IOException is a Stale NFS File Handle
-   *
-   * @return a boolean true if the IOException is a Stale NFS FIle Handle
-   */
-  public static boolean isStaleFileHandle(IOException ioe) {
-    String msg = ioe.getMessage();
-    return msg != null && msg.toLowerCase(Locale.ROOT).matches(".*stale .*file .*handle.*");
-  }
-
-  public static <T extends Throwable> void throwIfNotStaleFileHandle(T e) throws T {
-    if (!isStaleFileHandleInCausalChain(e)) {
-      throw e;
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java b/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
index 8b0aa3d..fc80781 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/OnStartStop.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index f9947ae..2b60145 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -54,6 +54,8 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationPushFilter;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -122,7 +124,7 @@
 
   private final Project.NameKey projectName;
   private final URIish uri;
-  private final Set<ImmutableSet<String>> refBatchesToPush = Sets.newHashSetWithExpectedSize(4);
+  private final Set<ImmutableSet<String>> refBatchesToPush = Sets.newConcurrentHashSet();
   private boolean pushAllRefs;
   private Repository git;
   private boolean isCollision;
@@ -357,7 +359,7 @@
   }
 
   ReplicationState[] getStatesByRef(String ref) {
-    Collection<ReplicationState> states = stateMap.get(ref);
+    List<ReplicationState> states = stateMap.get(ref);
     return states.toArray(new ReplicationState[states.size()]);
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
index 05b4066..726bcf5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
@@ -14,7 +14,6 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import com.google.common.collect.ImmutableList;
-import java.util.List;
 import org.eclipse.jgit.transport.RemoteConfig;
 
 /** Remote configuration for a replication endpoint */
@@ -117,7 +116,7 @@
    * @return true, when configuration is for a single project, false otherwise
    */
   default boolean isSingleProjectMatch() {
-    List<String> projects = getProjects();
+    ImmutableList<String> projects = getProjects();
     boolean ret = (projects.size() == 1);
     if (ret) {
       String projectMatch = projects.get(0);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
similarity index 67%
rename from src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java
rename to src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
index 93990f4..1223db0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -13,50 +13,54 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.replication;
 
-import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginData;
+import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-import java.io.IOException;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.nio.file.Path;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
-public class ReplicationFileBasedConfig implements ReplicationConfig {
+public class ReplicationConfigImpl implements ReplicationConfig {
   private static final int DEFAULT_SSH_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
 
   private final SitePaths site;
-  private Path cfgPath;
+  private final MergedConfigResource configResource;
   private boolean replicateAllOnPluginStart;
   private boolean defaultForceUpdate;
   private int maxRefsToLog;
   private final int maxRefsToShow;
   private int sshCommandTimeout;
-  private int sshConnectionTimeout = DEFAULT_SSH_CONNECTION_TIMEOUT_MS;
-  private final FileBasedConfig config;
+  private int sshConnectionTimeout;
   private final Path pluginDataDir;
+  private final Config config;
 
   @Inject
-  public ReplicationFileBasedConfig(SitePaths site, @PluginData Path pluginDataDir) {
+  public ReplicationConfigImpl(
+      MergedConfigResource configResource, SitePaths site, @PluginData Path pluginDataDir) {
     this.site = site;
-    this.cfgPath = site.etc_dir.resolve("replication.config");
-    this.config = new FileBasedConfig(cfgPath.toFile(), FS.DETECTED);
-    try {
-      config.load();
-    } catch (ConfigInvalidException e) {
-      repLog.atSevere().withCause(e).log("Config file %s is invalid: %s", cfgPath, e.getMessage());
-    } catch (IOException e) {
-      repLog.atSevere().withCause(e).log("Cannot read %s: %s", cfgPath, e.getMessage());
-    }
+    config = configResource.getConfig();
+    this.configResource = configResource;
     this.replicateAllOnPluginStart = config.getBoolean("gerrit", "replicateOnStartup", false);
     this.defaultForceUpdate = config.getBoolean("gerrit", "defaultForceUpdate", false);
     this.maxRefsToLog = config.getInt("gerrit", "maxRefsToLog", 0);
     this.maxRefsToShow = config.getInt("gerrit", "maxRefsToShow", 2);
+    this.sshCommandTimeout =
+        (int) ConfigUtil.getTimeUnit(config, "gerrit", null, "sshCommandTimeout", 0, SECONDS);
+    this.sshConnectionTimeout =
+        (int)
+            ConfigUtil.getTimeUnit(
+                config,
+                "gerrit",
+                null,
+                "sshConnectionTimeout",
+                DEFAULT_SSH_CONNECTION_TIMEOUT_MS,
+                MILLISECONDS);
     this.pluginDataDir = pluginDataDir;
   }
 
@@ -75,7 +79,7 @@
 
   /**
    * See {@link
-   * com.googlesource.gerrit.plugins.replication.ReplicationConfig#isReplicateAllOnPluginStart()}
+   * com.googlesource.gerrit.plugins.replication.api.ReplicationConfig#isReplicateAllOnPluginStart()}
    */
   @Override
   public boolean isReplicateAllOnPluginStart() {
@@ -84,7 +88,7 @@
 
   /**
    * See {@link
-   * com.googlesource.gerrit.plugins.replication.ReplicationConfig#isDefaultForceUpdate()}
+   * com.googlesource.gerrit.plugins.replication.api.ReplicationConfig#isDefaultForceUpdate()}
    */
   @Override
   public boolean isDefaultForceUpdate() {
@@ -93,7 +97,7 @@
 
   @Override
   public int getDistributionInterval() {
-    return config.getInt("replication", "distributionInterval", 0);
+    return getConfig().getInt("replication", "distributionInterval", 0);
   }
 
   @Override
@@ -108,17 +112,13 @@
 
   @Override
   public Path getEventsDirectory() {
-    String eventsDirectory = config.getString("replication", null, "eventsDirectory");
+    String eventsDirectory = getConfig().getString("replication", null, "eventsDirectory");
     if (!Strings.isNullOrEmpty(eventsDirectory)) {
       return site.resolve(eventsDirectory);
     }
     return pluginDataDir;
   }
 
-  Path getCfgPath() {
-    return cfgPath;
-  }
-
   @Override
   public Config getConfig() {
     return config;
@@ -126,7 +126,7 @@
 
   @Override
   public String getVersion() {
-    return Long.toString(config.getFile().lastModified());
+    return configResource.getVersion();
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java
new file mode 100644
index 0000000..b0343c9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigModule.java
@@ -0,0 +1,79 @@
+// 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.googlesource.gerrit.plugins.replication;
+
+import static com.googlesource.gerrit.plugins.replication.FanoutConfigResource.CONFIG_DIR;
+import static com.googlesource.gerrit.plugins.replication.FileConfigResource.CONFIG_NAME;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Scopes;
+import com.google.inject.internal.UniqueAnnotations;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+public class ReplicationConfigModule extends AbstractModule {
+
+  private final SitePaths site;
+  private final Path cfgPath;
+
+  @Inject
+  ReplicationConfigModule(SitePaths site) {
+    this.site = site;
+    this.cfgPath = site.etc_dir.resolve(CONFIG_NAME);
+  }
+
+  @Override
+  protected void configure() {
+    bind(ConfigResource.class).to(getConfigResourceClass());
+
+    if (getReplicationConfig().getBoolean("gerrit", "autoReload", false)) {
+      bind(ReplicationConfig.class).to(AutoReloadConfigDecorator.class).in(Scopes.SINGLETON);
+      bind(LifecycleListener.class)
+          .annotatedWith(UniqueAnnotations.create())
+          .to(AutoReloadConfigDecorator.class);
+    } else {
+      bind(ReplicationConfig.class).to(ReplicationConfigImpl.class).in(Scopes.SINGLETON);
+    }
+  }
+
+  public FileBasedConfig getReplicationConfig() {
+    File replicationConfigFile = cfgPath.toFile();
+    FileBasedConfig config = new FileBasedConfig(replicationConfigFile, FS.DETECTED);
+    try {
+      config.load();
+    } catch (IOException | ConfigInvalidException e) {
+      throw new ProvisionException("Unable to load " + replicationConfigFile.getAbsolutePath(), e);
+    }
+    return config;
+  }
+
+  private Class<? extends ConfigResource> getConfigResourceClass() {
+    if (Files.exists(site.etc_dir.resolve(CONFIG_DIR))) {
+      return FanoutConfigResource.class;
+    }
+    return FileConfigResource.class;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
index 78c1a35..bcc07e5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.Multimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.WorkQueue;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationExtensionPointModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationExtensionPointModule.java
index b92a54a..afbb54f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationExtensionPointModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationExtensionPointModule.java
@@ -14,8 +14,8 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.inject.AbstractModule;
+import com.googlesource.gerrit.plugins.replication.api.ApiModule;
 
 /**
  * Gerrit libModule for applying a ref-filter for outgoing replications.
@@ -27,6 +27,6 @@
 
   @Override
   protected void configure() {
-    DynamicItem.itemOf(binder(), ReplicationPushFilter.class);
+    install(new ApiModule());
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index 9f331f2..ac27280 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -24,11 +24,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.events.EventTypes;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
 import com.google.inject.Scopes;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.internal.UniqueAnnotations;
@@ -40,29 +38,22 @@
 import com.googlesource.gerrit.plugins.replication.events.RefReplicatedEvent;
 import com.googlesource.gerrit.plugins.replication.events.RefReplicationDoneEvent;
 import com.googlesource.gerrit.plugins.replication.events.ReplicationScheduledEvent;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import org.apache.http.impl.client.CloseableHttpClient;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.util.FS;
 
 class ReplicationModule extends AbstractModule {
-  private final SitePaths site;
-  private final Path cfgPath;
+
+  private final ReplicationConfigModule configModule;
 
   @Inject
-  public ReplicationModule(SitePaths site) {
-    this.site = site;
-    cfgPath = site.etc_dir.resolve("replication.config");
+  public ReplicationModule(ReplicationConfigModule configModule) {
+    this.configModule = configModule;
   }
 
   @Override
   protected void configure() {
     install(new FactoryModuleBuilder().build(Destination.Factory.class));
+    install(configModule);
     bind(ReplicationQueue.class).in(Scopes.SINGLETON);
     bind(ObservableQueue.class).to(ReplicationQueue.class);
     bind(LifecycleListener.class)
@@ -92,18 +83,6 @@
     bind(ReplicationDestinations.class).to(DestinationsCollection.class);
     bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON);
 
-    if (getReplicationConfig().getBoolean("gerrit", "autoReload", false)) {
-      bind(ReplicationConfig.class)
-          .annotatedWith(MainReplicationConfig.class)
-          .to(getReplicationConfigClass());
-      bind(ReplicationConfig.class).to(AutoReloadConfigDecorator.class).in(Scopes.SINGLETON);
-      bind(LifecycleListener.class)
-          .annotatedWith(UniqueAnnotations.create())
-          .to(AutoReloadConfigDecorator.class);
-    } else {
-      bind(ReplicationConfig.class).to(getReplicationConfigClass()).in(Scopes.SINGLETON);
-    }
-
     DynamicSet.setOf(binder(), ReplicationStateListener.class);
     DynamicSet.bind(binder(), ReplicationStateListener.class).to(ReplicationStateLogger.class);
 
@@ -125,22 +104,4 @@
     bind(TransportFactory.class).to(TransportFactoryImpl.class).in(Scopes.SINGLETON);
     bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class).in(Scopes.SINGLETON);
   }
-
-  private FileBasedConfig getReplicationConfig() {
-    File replicationConfigFile = cfgPath.toFile();
-    FileBasedConfig config = new FileBasedConfig(replicationConfigFile, FS.DETECTED);
-    try {
-      config.load();
-    } catch (IOException | ConfigInvalidException e) {
-      throw new ProvisionException("Unable to load " + replicationConfigFile.getAbsolutePath(), e);
-    }
-    return config;
-  }
-
-  private Class<? extends ReplicationConfig> getReplicationConfigClass() {
-    if (Files.exists(site.etc_dir.resolve("replication"))) {
-      return FanoutReplicationConfig.class;
-    }
-    return ReplicationFileBasedConfig.class;
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
index 4b5d7c1..6a36add 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -33,8 +33,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
 import com.googlesource.gerrit.plugins.replication.ReplicationTasksStorage.ReplicateRefUpdate;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import java.net.URISyntaxException;
 import java.util.Collection;
@@ -311,13 +312,15 @@
 
   private void fireBeforeStartupEvents() {
     Set<String> eventsReplayed = new HashSet<>();
-    for (ReferencesUpdatedEvent event : beforeStartupEventsQueue) {
+    ReferencesUpdatedEvent event;
+    while ((event = beforeStartupEventsQueue.peek()) != null) {
       String eventKey = String.format("%s:%s", event.projectName(), event.getRefNames());
       if (!eventsReplayed.contains(eventKey)) {
         repLog.atInfo().log("Firing pending task %s", event);
         fire(event.projectName(), event.updatedRefs());
         eventsReplayed.add(eventKey);
       }
+      beforeStartupEventsQueue.remove(event);
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
index 2e4b619..40f5278 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
@@ -33,6 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.nio.file.Files;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SshHelper.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SshHelper.java
index d73c101..6f5ba1e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/SshHelper.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SshHelper.java
@@ -16,6 +16,7 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
 import java.io.OutputStream;
 import org.eclipse.jgit.errors.TransportException;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
index 0cab7b1..a66cce6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
@@ -14,9 +14,16 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.sshd.PluginCommandModule;
+import com.google.inject.Inject;
 
 class SshModule extends PluginCommandModule {
+  @Inject
+  SshModule(@PluginName String pluginName) {
+    super(pluginName);
+  }
+
   @Override
   protected void configureCommands() {
     command(StartCommand.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
new file mode 100644
index 0000000..3764ede
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
@@ -0,0 +1,26 @@
+// 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.googlesource.gerrit.plugins.replication.api;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.AbstractModule;
+
+public class ApiModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicItem.itemOf(binder(), ReplicationPushFilter.class);
+    DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
new file mode 100644
index 0000000..43733bc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
@@ -0,0 +1,45 @@
+// 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.googlesource.gerrit.plugins.replication.api;
+
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Separates configuration resource from the configuration schema.
+ *
+ * <p>The {@code ConfigResource} splits configuration source from configuration options. The
+ * resource is just an {@code Config} object with its version. The configuration options can be
+ * loaded from different sources eg. a single file, multiple files or ZooKeeper.
+ *
+ * <p>Here we don't assume any configuration options being present in the {@link Config} object,
+ * this is the responsibility of {@link ReplicationConfig} interface. Think about {@code
+ * ConfigResource} as a "plain text file" and {@code ReplicationConfig} as a XML file.
+ */
+public interface ConfigResource {
+
+  /**
+   * Configuration for the plugin and all replication end points.
+   *
+   * @return current configuration
+   */
+  Config getConfig();
+
+  /**
+   * Current logical version string of the current configuration on the persistent storage.
+   *
+   * @return latest logical version number on the persistent storage
+   */
+  String getVersion();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
similarity index 97%
rename from src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
rename to src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
index ec75450..8ef4e2b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.replication;
+package com.googlesource.gerrit.plugins.replication.api;
 
 import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/MainReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfigOverrides.java
similarity index 61%
rename from src/main/java/com/googlesource/gerrit/plugins/replication/MainReplicationConfig.java
rename to src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfigOverrides.java
index e8d95ec..b6fbc1a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/MainReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfigOverrides.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2020 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.
@@ -12,12 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.replication;
+package com.googlesource.gerrit.plugins.replication.api;
 
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-@BindingAnnotation
-@Retention(RetentionPolicy.RUNTIME)
-public @interface MainReplicationConfig {}
+/**
+ * Provide a way to override or extend replication configuration from other sources, like git
+ * repository or external configuration management tool.
+ */
+public interface ReplicationConfigOverrides extends ConfigResource {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationPushFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationPushFilter.java
similarity index 94%
rename from src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationPushFilter.java
rename to src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationPushFilter.java
index eb6ba90..afbdff6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationPushFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationPushFilter.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.replication;
+package com.googlesource.gerrit.plugins.replication.api;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import java.util.List;
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 8f1ca86..6f9a49d 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -302,7 +302,7 @@
 	to create/remove projects and update repository HEAD references.
 
 	NOTE: In order to replicate project deletion, the
-	link:https://gerrit-review.googlesource.com/admin/projects/plugins/delete-project delete-project[delete-project]
+	[delete-project](https://gerrit-review.googlesource.com/admin/projects/plugins/delete-project)
 	plugin must be installed on the other Gerrit.
 
 	*Backward compatibility notice*
diff --git a/src/main/resources/Documentation/extension-point.md b/src/main/resources/Documentation/extension-point.md
index f6579fa..fab5fbc 100644
--- a/src/main/resources/Documentation/extension-point.md
+++ b/src/main/resources/Documentation/extension-point.md
@@ -26,19 +26,6 @@
 Extension points
 ----------------
 
-* `com.googlesource.gerrit.plugins.replication.ReplicationPushFilter`
-
-  Filter out the ref updates pushed to a remote instance.
-  Only one filter at a time is supported. Filter implementation needs to bind a `DynamicItem`.
-
-  Default: no filtering
-
-  Example:
-
-  ```java
-  DynamicItem.bind(binder(), ReplicationPushFilter.class).to(ReplicationPushFilterImpl.class);
-  ```
-
 * `com.googlesource.gerrit.plugins.replication.AdminApiFactory`
 
   Create an instance of `AdminApi` for a given remote URL. The default implementation
@@ -52,3 +39,72 @@
   ```java
   DynamicItem.bind(binder(), AdminApiFactory.class).to(AdminApiFactoryImpl.class);
   ```
+
+@PLUGIN@ Cross Plugin Communication
+===================================
+
+The @PLUGIN@ plugin exposes _ApiModule_ that allows to provide _Cross Plugin
+Communication_.  Extension points can be defined from the replication plugin when it is loaded
+as [ApiModule](../../../Documentation/dev-plugins.html#_cross_plugin_communication) and
+implemented by another plugin by declaring a `provided` dependency from the replication plugin api.
+
+Build the @plugin@'s API jar
+----------------------------
+
+The replication plugin's extension points are defined in the `c.g.g.p.r.a.ApiModule`
+that needs to be built from source and loaded in Gerrit as ApiModule.
+
+```
+$ bazelisk build plugins/replication:replication-api
+$ cp bazel-bin/plugins/replication/replication-api.jar $GERRIT_SITE/plugins
+```
+
+> **NOTE**: Use and configuration of the replication-api as ApiModule is compatible with
+> Gerrit v3.9 onwards and requires a Gerrit server restart; it does not support hot plugin install
+> or upgrade.
+
+
+Setup
+-----
+
+Check the [official documentation](https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#_cross_plugin_communication)
+on how to setup your project.
+
+Working with [Extension Points](./extension-point.md)
+-----------------------------------------------------
+
+In order to use both, the _Cross Plugin Communication_ and replication
+_Extension Points_, follow the [Install extension libModule](./extension-point.md#install-extension-libmodule)
+steps and make sure that `replication.jar` is only present in `lib/` directory.
+
+Exposed API
+-----------
+
+* `com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides`
+
+  Override current replication configuration from external source (eg. git
+  repository, ZooKeeper).
+
+  Replication plugin will still use configuration from `$gerrit_site/etc/`, but
+  with overrides it can be modified dynamically from external source, similarly to
+  how `git config` uses _user_ and _repository_ configuration files.
+
+  Only one override at a time is supported. The implementation needs to bind a
+  `DynamicItem`.
+
+  ```java
+  DynamicItem.bind(binder(), ReplicationConfigOverrides.class).to(ReplicationConfigOverridesImpl.class);
+  ```
+
+* `com.googlesource.gerrit.plugins.replication.api.ReplicationPushFilter`
+
+  Filter out the ref updates pushed to a remote instance.
+  Only one filter at a time is supported. Filter implementation needs to bind a `DynamicItem`.
+
+  Default: no filtering
+
+  Example:
+
+  ```java
+  DynamicItem.bind(binder(), ReplicationPushFilter.class).to(ReplicationPushFilterImpl.class);
+  ```
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
index 2b6a8c4..b8bbc08 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
@@ -27,6 +27,8 @@
 import com.google.inject.Injector;
 import com.google.inject.Module;
 import com.google.inject.util.Providers;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.List;
@@ -128,6 +130,13 @@
     assertThatIsDestination(matchingDestinations.get(0), remoteName, remoteUrls);
   }
 
+  protected DestinationsCollection newDestinationsCollections(ConfigResource configResource)
+      throws ConfigInvalidException {
+    return newDestinationsCollections(
+        new ReplicationConfigImpl(
+            MergedConfigResource.withBaseOnly(configResource), sitePaths, pluginDataPath));
+  }
+
   protected DestinationsCollection newDestinationsCollections(ReplicationConfig replicationConfig)
       throws ConfigInvalidException {
     return new DestinationsCollection(
@@ -138,7 +147,10 @@
         eventBus);
   }
 
-  protected ReplicationConfig newReplicationFileBasedConfig() {
-    return new ReplicationFileBasedConfig(sitePaths, pluginDataPath);
+  protected ReplicationConfigImpl newReplicationFileBasedConfig() {
+    return new ReplicationConfigImpl(
+        MergedConfigResource.withBaseOnly(new FileConfigResource(sitePaths)),
+        sitePaths,
+        pluginDataPath);
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecoratorTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecoratorTest.java
index b1b9453..021707a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecoratorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecoratorTest.java
@@ -18,7 +18,8 @@
 
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -75,12 +76,19 @@
     remoteConfig.setString("remote", null, "url", remoteUrl1);
     remoteConfig.save();
 
-    replicationConfig = new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    replicationConfig =
+        new ReplicationConfigImpl(
+            MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+            sitePaths,
+            pluginDataPath);
 
     newAutoReloadConfig(
             () -> {
               try {
-                return new FanoutReplicationConfig(sitePaths, pluginDataPath);
+                return new ReplicationConfigImpl(
+                    MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+                    sitePaths,
+                    pluginDataPath);
               } catch (IOException | ConfigInvalidException e) {
                 throw new RuntimeException(e);
               }
@@ -122,12 +130,19 @@
     remoteConfig.setString("remote", null, "url", remoteUrl2);
     remoteConfig.save();
 
-    replicationConfig = new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    replicationConfig =
+        new ReplicationConfigImpl(
+            MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+            sitePaths,
+            pluginDataPath);
 
     newAutoReloadConfig(
             () -> {
               try {
-                return new FanoutReplicationConfig(sitePaths, pluginDataPath);
+                return new ReplicationConfigImpl(
+                    MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+                    sitePaths,
+                    pluginDataPath);
               } catch (IOException | ConfigInvalidException e) {
                 throw new RuntimeException(e);
               }
@@ -168,12 +183,19 @@
     remoteConfig.setString("remote", null, "url", remoteUrl2);
     remoteConfig.save();
 
-    replicationConfig = new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    replicationConfig =
+        new ReplicationConfigImpl(
+            MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+            sitePaths,
+            pluginDataPath);
 
     newAutoReloadConfig(
             () -> {
               try {
-                return new FanoutReplicationConfig(sitePaths, pluginDataPath);
+                return new ReplicationConfigImpl(
+                    MergedConfigResource.withBaseOnly(new FanoutConfigResource(sitePaths)),
+                    sitePaths,
+                    pluginDataPath);
               } catch (IOException | ConfigInvalidException e) {
                 throw new RuntimeException(e);
               }
@@ -228,24 +250,20 @@
   }
 
   private AutoReloadConfigDecorator newAutoReloadConfig(
-      Supplier<ReplicationConfig> configSupplier) {
+      Supplier<ReplicationConfigImpl> configSupplier) {
     AutoReloadRunnable autoReloadRunnable =
         new AutoReloadRunnable(
             configParser,
-            new Provider<ReplicationConfig>() {
+            new Provider<ReplicationConfigImpl>() {
 
               @Override
-              public ReplicationConfig get() {
+              public ReplicationConfigImpl get() {
                 return configSupplier.get();
               }
             },
             eventBus,
             Providers.of(replicationQueueMock));
     return new AutoReloadConfigDecorator(
-        "replication",
-        workQueueMock,
-        newReplicationFileBasedConfig(),
-        autoReloadRunnable,
-        eventBus);
+        "replication", workQueueMock, autoReloadRunnable, eventBus);
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
index 725052c..2040edb 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
@@ -81,11 +81,14 @@
     autoReloadRunnable.run();
   }
 
-  private Provider<ReplicationConfig> newVersionConfigProvider() {
+  private Provider<ReplicationConfigImpl> newVersionConfigProvider() {
     return new Provider<>() {
       @Override
-      public ReplicationConfig get() {
-        return new ReplicationFileBasedConfig(sitePaths, sitePaths.data_dir) {
+      public ReplicationConfigImpl get() {
+        return new ReplicationConfigImpl(
+            MergedConfigResource.withBaseOnly(new FileConfigResource(sitePaths)),
+            sitePaths,
+            sitePaths.data_dir) {
           @Override
           public String getVersion() {
             return String.format("%s", System.nanoTime());
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
similarity index 81%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfigTest.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
index 8cba4bb..9147ea1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutReplicationConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
@@ -16,9 +16,11 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.replication.FanoutConfigResource.CONFIG_DIR;
 
 import com.google.common.io.MoreFiles;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
+import java.io.File;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -27,9 +29,9 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class FanoutReplicationConfigTest extends AbstractConfigTest {
+public class FanoutConfigResourceTest extends AbstractConfigTest {
 
-  public FanoutReplicationConfigTest() throws IOException {
+  public FanoutConfigResourceTest() throws IOException {
     super();
   }
 
@@ -39,13 +41,19 @@
   String remoteUrl2 = "ssh://git@git.elsewhere.com/${name}";
 
   @Before
-  public void setupTests() {
+  public void setupTests() throws Exception {
     FileBasedConfig config = newReplicationConfig();
     try {
       config.save();
     } catch (IOException e) {
       throw new RuntimeException(e);
     }
+    File replicationConfig = sitePaths.etc_dir.resolve(CONFIG_DIR).toFile();
+    if (!replicationConfig.mkdir()) {
+      throw new IOException(
+          "Cannot create test replication config directory in: "
+              + replicationConfig.toPath().toAbsolutePath());
+    }
   }
 
   @Test
@@ -62,7 +70,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(1);
 
@@ -81,7 +89,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(2);
 
@@ -101,7 +109,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(1);
 
@@ -123,7 +131,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(1);
 
@@ -136,7 +144,7 @@
     config.setString("remote", null, "url", "ssh://git@git.elsewhere.com/name");
     config.save();
 
-    newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+    newDestinationsCollections(new FanoutConfigResource(sitePaths));
   }
 
   @Test
@@ -145,7 +153,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(0);
   }
@@ -158,7 +166,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(0);
   }
@@ -170,7 +178,7 @@
     config.save();
 
     DestinationsCollection destinationsCollections =
-        newDestinationsCollections(new FanoutReplicationConfig(sitePaths, pluginDataPath));
+        newDestinationsCollections(new FanoutConfigResource(sitePaths));
     List<Destination> destinations = destinationsCollections.getAll(FilterType.ALL);
     assertThat(destinations).hasSize(0);
   }
@@ -186,12 +194,11 @@
     config.setString("remote", null, "url", remoteUrl2);
     config.save();
 
-    FanoutReplicationConfig objectUnderTest =
-        new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
 
     String version = objectUnderTest.getVersion();
 
-    objectUnderTest = new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    objectUnderTest = new FanoutConfigResource(sitePaths);
 
     assertThat(objectUnderTest.getVersion()).isEqualTo(version);
   }
@@ -203,8 +210,7 @@
     config.setString("remote", null, "url", remoteUrl1);
     config.save();
 
-    FanoutReplicationConfig objectUnderTest =
-        new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
 
     String version = objectUnderTest.getVersion();
 
@@ -222,8 +228,7 @@
     config.setString("remote", null, "url", remoteUrl1);
     config.save();
 
-    FanoutReplicationConfig objectUnderTest =
-        new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
 
     String version = objectUnderTest.getVersion();
 
@@ -244,8 +249,7 @@
     config.setString("remote", null, "url", remoteUrl2);
     config.save();
 
-    FanoutReplicationConfig objectUnderTest =
-        new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
 
     String version = objectUnderTest.getVersion();
     assertThat(
@@ -267,11 +271,14 @@
     config.setString("remote", null, "url", remoteUrl2);
     config.save();
 
-    FanoutReplicationConfig objectUnderTest =
-        new FanoutReplicationConfig(sitePaths, pluginDataPath);
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
 
     String replicationConfigVersion =
-        new ReplicationFileBasedConfig(sitePaths, pluginDataPath).getVersion();
+        new ReplicationConfigImpl(
+                MergedConfigResource.withBaseOnly(new FileConfigResource(sitePaths)),
+                sitePaths,
+                pluginDataPath)
+            .getVersion();
 
     MoreFiles.deleteRecursively(sitePaths.etc_dir.resolve("replication"), ALLOW_INSECURE);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
new file mode 100644
index 0000000..18bb603
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
@@ -0,0 +1,116 @@
+// 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.googlesource.gerrit.plugins.replication;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.googlesource.gerrit.plugins.replication.api.ApiModule;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class MergedConfigResourceTest {
+  private static final int BASE_CONFIG_MAX_RETIRES = 10;
+  private static final int OVERRIDDEN_CONFIG_MAX_RETIRES = 5;
+
+  @Test
+  public void onlyUseBaseConfig() {
+    final MergedConfigResource configResource = newMergedConfigResource();
+
+    assertThat(configResource.getVersion()).isEqualTo("base");
+    assertThat(getMaxRetires(configResource)).isEqualTo(BASE_CONFIG_MAX_RETIRES);
+  }
+
+  @Test
+  public void overrideBaseConfig() {
+    final MergedConfigResource configResource =
+        newMergedConfigResource(TestReplicationConfigOverrides.class);
+
+    assertThat(configResource.getVersion()).isEqualTo("baseoverride");
+    assertThat(getMaxRetires(configResource)).isEqualTo(OVERRIDDEN_CONFIG_MAX_RETIRES);
+    assertThat(getUseGcClient(configResource)).isTrue();
+  }
+
+  private MergedConfigResource newMergedConfigResource() {
+    return newMergedConfigResource(null);
+  }
+
+  private MergedConfigResource newMergedConfigResource(
+      Class<? extends ReplicationConfigOverrides> overrides) {
+    return Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                install(new ApiModule());
+
+                bind(ConfigResource.class).to(TestBaseConfigResource.class);
+
+                if (overrides != null) {
+                  DynamicItem.bind(binder(), ReplicationConfigOverrides.class).to(overrides);
+                }
+              }
+            })
+        .getInstance(MergedConfigResource.class);
+  }
+
+  private static class TestBaseConfigResource implements ConfigResource {
+    @Override
+    public Config getConfig() {
+      Config config = new Config();
+      setMaxRetires(config, BASE_CONFIG_MAX_RETIRES);
+      return config;
+    }
+
+    @Override
+    public String getVersion() {
+      return "base";
+    }
+  }
+
+  private static class TestReplicationConfigOverrides implements ReplicationConfigOverrides {
+    @Override
+    public Config getConfig() {
+      Config config = new Config();
+      setMaxRetires(config, OVERRIDDEN_CONFIG_MAX_RETIRES);
+      setUseGcClient(config, true);
+      return config;
+    }
+
+    @Override
+    public String getVersion() {
+      return "override";
+    }
+  }
+
+  private static void setMaxRetires(Config config, int value) {
+    config.setInt("replication", null, "maxRetries", value);
+  }
+
+  private static void setUseGcClient(Config config, boolean value) {
+    config.setBoolean("replication", null, "useGcClient", value);
+  }
+
+  private static int getMaxRetires(MergedConfigResource resource) {
+    return resource.getConfig().getInt("replication", null, "maxRetries", -1);
+  }
+
+  private static boolean getUseGcClient(MergedConfigResource resource) {
+    return resource.getConfig().getBoolean("replication", null, "useGcClient", false);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
index 21fe85c..aa29846 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/PushOneTest.java
@@ -39,6 +39,8 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.util.IdGenerator;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationPushFilter;
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImplTest.java
similarity index 93%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfigTest.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImplTest.java
index 79b05cc..993e408 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImplTest.java
@@ -16,15 +16,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.junit.Test;
 
-public class ReplicationFileBasedConfigTest extends AbstractConfigTest {
+public class ReplicationConfigImplTest extends AbstractConfigTest {
 
-  public ReplicationFileBasedConfigTest() throws IOException {
+  public ReplicationConfigImplTest() throws IOException {
     super();
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
index 3bc86c7..ba4a958 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
@@ -49,7 +49,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationDaemon extends LightweightPluginDaemonTest {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   protected static final Optional<String> ALL_PROJECTS = Optional.empty();
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDistributorIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDistributorIT.java
index dfcf250..dc73036 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDistributorIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDistributorIT.java
@@ -39,7 +39,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationDistributorIT extends ReplicationStorageDaemon {
   private static final int TEST_DISTRIBUTION_INTERVAL_SECONDS = 3;
   private static final int TEST_DISTRIBUTION_DURATION_SECONDS = 1;
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
index b32829c..f31d013 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationEventsIT.java
@@ -58,7 +58,7 @@
 @Sandboxed
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationEventsIT extends ReplicationDaemon {
   private static final Duration TEST_POST_EVENT_TIMEOUT = Duration.ofSeconds(1);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFanoutIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFanoutIT.java
index 5f80e8c..0d222b7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFanoutIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationFanoutIT.java
@@ -43,7 +43,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationFanoutIT extends ReplicationDaemon {
   private ReplicationTasksStorage tasksStorage;
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
index 9285c58..4414cec 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -52,7 +52,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationIT extends ReplicationDaemon {
   private static final int TEST_REPLICATION_DELAY = 1;
   private static final int TEST_REPLICATION_RETRY = 1;
@@ -371,7 +371,6 @@
     Result pushResult = createChange();
     shutdownDestinations();
 
-    pushResult.getCommit();
     String sourceRef = pushResult.getPatchSet().refName();
 
     assertThrows(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationPushInBatchesIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationPushInBatchesIT.java
index cf8dbe3..67caae9 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationPushInBatchesIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationPushInBatchesIT.java
@@ -29,7 +29,7 @@
 
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationPushInBatchesIT extends ReplicationDaemon {
 
   @Override
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageDaemon.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageDaemon.java
index b6d14e8..a6ca305 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageDaemon.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageDaemon.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
index 9390798..e800cff 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.googlesource.gerrit.plugins.replication.Destination.QueueInfo;
-import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
 import com.googlesource.gerrit.plugins.replication.ReplicationTasksStorage.ReplicateRefUpdate;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType;
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.nio.file.Files;
@@ -49,7 +49,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationStorageIT extends ReplicationStorageDaemon {
 
   @Test
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageMPIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageMPIT.java
index 1001e6c..45cf310 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageMPIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageMPIT.java
@@ -34,7 +34,7 @@
 @UseLocalDisk
 @TestPlugin(
     name = "replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.ReplicationModule")
+    sysModule = "com.googlesource.gerrit.plugins.replication.TestReplicationModule")
 public class ReplicationStorageMPIT extends ReplicationStorageDaemon {
 
   @Test
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/TestReplicationModule.java b/src/test/java/com/googlesource/gerrit/plugins/replication/TestReplicationModule.java
new file mode 100644
index 0000000..a01a2cf
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/TestReplicationModule.java
@@ -0,0 +1,34 @@
+// 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.googlesource.gerrit.plugins.replication;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.api.ApiModule;
+
+public class TestReplicationModule extends AbstractModule {
+  private final ReplicationModule replicationModule;
+
+  @Inject
+  TestReplicationModule(ReplicationModule replicationModule) {
+    this.replicationModule = replicationModule;
+  }
+
+  @Override
+  protected void configure() {
+    install(new ApiModule());
+    install(replicationModule);
+  }
+}