Merge branch 'stable-3.8'

* stable-3.8:
  Rename metric tasks/max_retries to tasks/failed_max_retries
  Document metrics
  Fix completed tasks metrics
  Log fetch tasks when graceful shutdown fails
  Displaying refs for fetch tasks
  Leverage ShutdownState in SourcesCollection
  Fix pull replication queue metric prefix
  Set pull replication tests as large
  Introduce replication queue metrics
  Handle fetch tasks gracefully during shutdown
  Add integration test for new project creation
  Do not honor gerrit.replicateOnStartup on primary side
  Update gerrit docker image to 3.8.1-almalinux9

Change-Id: Ie5d9a96b88c45a7aa62953c22609017370de2dd8
diff --git a/BUILD b/BUILD
index 972f0d8..c139c5a 100644
--- a/BUILD
+++ b/BUILD
@@ -17,6 +17,7 @@
     resources = glob(["src/main/resources/**/*"]),
     deps = [
         "//lib/commons:io",
+        "//plugins/delete-project",
         "//plugins/replication",
         "@commons-lang3//jar",
         "@events-broker//jar:neverlink",
@@ -34,6 +35,7 @@
     deps = PLUGIN_TEST_DEPS + PLUGIN_DEPS + [
         ":pull-replication__plugin",
         ":pull_replication_util",
+        "//plugins/delete-project",
         "//plugins/replication",
         "@events-broker//jar",
     ],
@@ -63,6 +65,7 @@
     ),
     deps = PLUGIN_TEST_DEPS + PLUGIN_DEPS + [
         ":pull-replication__plugin",
+        "//plugins/delete-project",
         "//plugins/replication",
     ],
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
index 49a0ca1..93dfeb8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
@@ -53,7 +53,6 @@
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.SourceHttpClient;
 import com.googlesource.gerrit.plugins.replication.pull.event.EventsBrokerConsumerModule;
-import com.googlesource.gerrit.plugins.replication.pull.event.FetchRefReplicatedEventModule;
 import com.googlesource.gerrit.plugins.replication.pull.event.StreamEventModule;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
 import java.io.File;
@@ -94,8 +93,6 @@
     install(new FactoryModuleBuilder().build(FetchJob.Factory.class));
     install(new ApplyObjectCacheModule());
 
-    install(new FetchRefReplicatedEventModule());
-
     install(
         new FactoryModuleBuilder()
             .implement(HttpClient.class, SourceHttpClient.class)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
index 1ba47de..a698ac4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -26,6 +26,9 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.metrics.Timer1.Context;
 import com.google.gerrit.server.config.GerritInstanceId;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.BatchRefUpdateEvent;
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.RefUpdatedEvent;
@@ -35,6 +38,7 @@
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.replication.ObservableQueue;
 import com.googlesource.gerrit.plugins.replication.pull.FetchResultProcessing.GitUpdateProcessing;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.BatchApplyObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient;
@@ -42,8 +46,8 @@
 import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectsRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.net.URISyntaxException;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -55,12 +59,12 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
-import org.apache.http.client.ClientProtocolException;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.URIish;
 import org.slf4j.Logger;
@@ -78,6 +82,7 @@
   static final Logger repLog = LoggerFactory.getLogger(PULL_REPLICATION_LOG_NAME);
 
   private static final Integer DEFAULT_FETCH_CALLS_TIMEOUT = 0;
+  private static final String BATCH_REF_UPDATED_EVENT_TYPE = BatchRefUpdateEvent.TYPE;
   private static final String REF_UDPATED_EVENT_TYPE = new RefUpdatedEvent().type;
   private static final String ZEROS_OBJECTID = ObjectId.zeroId().getName();
   private final ReplicationStateListener stateLog;
@@ -88,7 +93,7 @@
   private final Provider<SourcesCollection> sources; // For Guice circular dependency
   private volatile boolean running;
   private volatile boolean replaying;
-  private final Queue<ReferenceUpdatedEvent> beforeStartupEventsQueue;
+  private final Queue<ReferenceBatchUpdatedEvent> beforeStartupEventsQueue;
   private FetchApiClient.Factory fetchClientFactory;
   private Integer fetchCallsTimeout;
   private ExcludedRefsFilter refsFilter;
@@ -97,6 +102,7 @@
   private final FetchReplicationMetrics fetchMetrics;
   private final ReplicationQueueMetrics queueMetrics;
   private final String instanceId;
+  private final boolean useBatchUpdateEvents;
   private ApplyObjectsRefsFilter applyObjectsRefsFilter;
 
   @Inject
@@ -112,6 +118,7 @@
       FetchReplicationMetrics fetchMetrics,
       ReplicationQueueMetrics queueMetrics,
       @GerritInstanceId String instanceId,
+      @GerritServerConfig Config gerritConfig,
       ApplyObjectsRefsFilter applyObjectsRefsFilter,
       ShutdownState shutdownState) {
     workQueue = wq;
@@ -127,6 +134,8 @@
     this.fetchMetrics = fetchMetrics;
     this.queueMetrics = queueMetrics;
     this.instanceId = instanceId;
+    this.useBatchUpdateEvents =
+        gerritConfig.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false);
     this.applyObjectsRefsFilter = applyObjectsRefsFilter;
   }
 
@@ -170,18 +179,61 @@
 
   @Override
   public void onEvent(com.google.gerrit.server.events.Event e) {
-    if (e.type.equals(REF_UDPATED_EVENT_TYPE) && instanceId.equals(e.instanceId)) {
+    if (!instanceId.equals(e.instanceId)) {
+      return;
+    }
+
+    if (useBatchUpdateEvents) {
+      if (e.type.equals(BATCH_REF_UPDATED_EVENT_TYPE)) {
+        BatchRefUpdateEvent event = (BatchRefUpdateEvent) e;
+
+        long eventCreatedOn = e.eventCreatedOn;
+        List<ReferenceUpdatedEvent> refs =
+            event.refUpdates.get().stream()
+                .filter(u -> isRefToBeReplicated(u.refName))
+                .map(
+                    u -> {
+                      repLog.info(
+                          "Ref event received: {} on project {}:{} - {} => {}",
+                          refUpdateType(u.oldRev, u.newRev),
+                          event.getProjectNameKey().get(),
+                          u.refName,
+                          u.oldRev,
+                          u.newRev);
+                      return ReferenceUpdatedEvent.from(u, eventCreatedOn);
+                    })
+                .sorted(ReplicationQueue::sortByMetaRefAsLast)
+                .collect(Collectors.toList());
+
+        if (!refs.isEmpty()) {
+          ReferenceBatchUpdatedEvent referenceBatchUpdatedEvent =
+              ReferenceBatchUpdatedEvent.create(
+                  event.getProjectNameKey().get(), refs, eventCreatedOn);
+          fire(referenceBatchUpdatedEvent);
+        }
+      }
+      return;
+    }
+
+    if (e.type.equals(REF_UDPATED_EVENT_TYPE)) {
       RefUpdatedEvent event = (RefUpdatedEvent) e;
 
       if (isRefToBeReplicated(event.getRefName())) {
+        RefUpdateAttribute refUpdateAttribute = event.refUpdate.get();
         repLog.info(
             "Ref event received: {} on project {}:{} - {} => {}",
-            refUpdateType(event),
-            event.refUpdate.get().project,
-            event.getRefName(),
-            event.refUpdate.get().oldRev,
-            event.refUpdate.get().newRev);
-        fire(ReferenceUpdatedEvent.from(event));
+            refUpdateType(refUpdateAttribute.oldRev, refUpdateAttribute.newRev),
+            event.getProjectNameKey().get(),
+            refUpdateAttribute.refName,
+            refUpdateAttribute.oldRev,
+            refUpdateAttribute.newRev);
+
+        ReferenceBatchUpdatedEvent referenceBatchUpdatedEvent =
+            ReferenceBatchUpdatedEvent.create(
+                event.getProjectNameKey().get(),
+                List.of(ReferenceUpdatedEvent.from(refUpdateAttribute, e.eventCreatedOn)),
+                e.eventCreatedOn);
+        fire(referenceBatchUpdatedEvent);
       }
     }
   }
@@ -196,10 +248,16 @@
                 source.getApis().forEach(apiUrl -> source.scheduleDeleteProject(apiUrl, project)));
   }
 
-  private static String refUpdateType(RefUpdatedEvent event) {
-    if (ZEROS_OBJECTID.equals(event.refUpdate.get().oldRev)) {
+  private static int sortByMetaRefAsLast(ReferenceUpdatedEvent a, ReferenceUpdatedEvent b) {
+    repLog.debug("sortByMetaRefAsLast({} <=> {})", a.refName(), b.refName());
+    return Boolean.compare(
+        RefNames.isNoteDbMetaRef(a.refName()), RefNames.isNoteDbMetaRef(b.refName()));
+  }
+
+  private static String refUpdateType(String oldRev, String newRev) {
+    if (ZEROS_OBJECTID.equals(oldRev)) {
       return "CREATE";
-    } else if (ZEROS_OBJECTID.equals(event.refUpdate.get().newRev)) {
+    } else if (ZEROS_OBJECTID.equals(newRev)) {
       return "DELETE";
     } else {
       return "UPDATE";
@@ -210,13 +268,13 @@
     return !refsFilter.match(refName);
   }
 
-  private void fire(ReferenceUpdatedEvent event) {
+  private void fire(ReferenceBatchUpdatedEvent event) {
     ReplicationState state = new ReplicationState(new GitUpdateProcessing(dispatcher.get()));
     fire(event, state);
     state.markAllFetchTasksScheduled();
   }
 
-  private void fire(ReferenceUpdatedEvent event, ReplicationState state) {
+  private void fire(ReferenceBatchUpdatedEvent event, ReplicationState state) {
     if (!running) {
       stateLog.warn(
           "Replication plugin did not finish startup before event, event replication is postponed",
@@ -238,12 +296,7 @@
 
       final Consumer<Source> callFunction =
           callFunction(
-              Project.nameKey(event.projectName()),
-              event.objectId(),
-              event.refName(),
-              event.eventCreatedOn(),
-              event.isDelete(),
-              state);
+              Project.nameKey(event.projectName()), event.refs(), event.eventCreatedOn(), state);
       fetchCallsPool
           .submit(() -> allSources.parallelStream().forEach(callFunction))
           .get(fetchCallsTimeout, TimeUnit.MILLISECONDS);
@@ -263,13 +316,10 @@
 
   private Consumer<Source> callFunction(
       NameKey project,
-      ObjectId objectId,
-      String refName,
+      List<ReferenceUpdatedEvent> refs,
       long eventCreatedOn,
-      boolean isDelete,
       ReplicationState state) {
-    CallFunction call =
-        getCallFunction(project, objectId, refName, eventCreatedOn, isDelete, state);
+    CallFunction call = getCallFunction(project, refs, eventCreatedOn, state);
 
     return (source) -> {
       boolean callSuccessful;
@@ -278,60 +328,65 @@
       } catch (Exception e) {
         repLog.warn(
             String.format(
-                "Failed to apply object %s on project %s:%s, falling back to git fetch",
-                objectId.name(), project, refName),
+                "Failed to batch apply object %s on project %s, falling back to git fetch",
+                refs.stream()
+                    .map(event -> String.format("%s:%s", event.refName(), event.objectId()))
+                    .collect(Collectors.joining(",")),
+                project),
             e);
         callSuccessful = false;
       }
 
       if (!callSuccessful) {
-        callFetch(source, project, refName, state);
+        callFetch(source, project, refs, state);
       }
     };
   }
 
   private CallFunction getCallFunction(
       NameKey project,
-      ObjectId objectId,
-      String refName,
+      List<ReferenceUpdatedEvent> refs,
       long eventCreatedOn,
-      boolean isDelete,
       ReplicationState state) {
-    if (isDelete) {
-      return ((source) ->
-          callSendObject(source, project, refName, eventCreatedOn, isDelete, null, state));
-    }
 
     try {
-      Optional<RevisionData> revisionData =
-          revReaderProvider.get().read(project, objectId, refName, 0);
-      repLog.info(
-          "RevisionData is {} for {}:{}",
-          revisionData.map(RevisionData::toString).orElse("ABSENT"),
-          project,
-          refName);
+      List<BatchApplyObjectData> refsBatch =
+          refs.stream()
+              .map(ref -> toBatchApplyObject(project, ref, state))
+              .collect(Collectors.toList());
 
-      if (revisionData.isPresent()) {
-        return ((source) ->
-            callSendObject(
-                source,
-                project,
-                refName,
-                eventCreatedOn,
-                isDelete,
-                Arrays.asList(revisionData.get()),
-                state));
+      if (!containsLargeRef(refsBatch)) {
+        return ((source) -> callBatchSendObject(source, project, refsBatch, eventCreatedOn, state));
       }
-    } catch (InvalidObjectIdException | IOException e) {
+    } catch (UncheckedIOException e) {
+      stateLog.error("Falling back to calling fetch", e, state);
+    }
+    return ((source) -> callFetch(source, project, refs, state));
+  }
+
+  private BatchApplyObjectData toBatchApplyObject(
+      NameKey project, ReferenceUpdatedEvent event, ReplicationState state) {
+    if (event.isDelete()) {
+      Optional<RevisionData> noRevisionData = Optional.empty();
+      return BatchApplyObjectData.create(event.refName(), noRevisionData, event.isDelete());
+    }
+    try {
+      Optional<RevisionData> maybeRevisionData =
+          revReaderProvider.get().read(project, event.objectId(), event.refName(), 0);
+      return BatchApplyObjectData.create(event.refName(), maybeRevisionData, event.isDelete());
+    } catch (IOException e) {
       stateLog.error(
           String.format(
               "Exception during reading ref: %s, project:%s, message: %s",
-              refName, project.get(), e.getMessage()),
+              event.refName(), project.get(), e.getMessage()),
           e,
           state);
+      throw new UncheckedIOException(e);
     }
+  }
 
-    return (source) -> callFetch(source, project, refName, state);
+  private boolean containsLargeRef(List<BatchApplyObjectData> batchApplyObjectData) {
+    return batchApplyObjectData.stream().anyMatch(e -> e.revisionData().isEmpty() && !e.isDelete());
   }
 
   private boolean callSendObject(
@@ -435,6 +490,126 @@
     return resultIsSuccessful;
   }
 
+  private boolean callBatchSendObject(
+      Source source,
+      NameKey project,
+      List<BatchApplyObjectData> refsBatch,
+      long eventCreatedOn,
+      ReplicationState state)
+      throws MissingParentObjectException {
+    boolean batchResultSuccessful = true;
+
+    List<BatchApplyObjectData> filteredRefsBatch =
+        refsBatch.stream()
+            .filter(r -> source.wouldFetchProject(project) && source.wouldFetchRef(r.refName()))
+            .collect(Collectors.toList());
+
+    String batchApplyObjectStr =
+        filteredRefsBatch.stream()
+            .map(BatchApplyObjectData::toString)
+            .collect(Collectors.joining(","));
+    FetchApiClient fetchClient = fetchClientFactory.create(source);
+
+    for (String apiUrl : source.getApis()) {
+      try {
+        URIish uri = new URIish(apiUrl);
+        repLog.info(
+            "Pull replication REST API batch apply object to {} for {}:[{}]",
+            apiUrl,
+            project,
+            batchApplyObjectStr);
+        Context<String> apiTimer = applyObjectMetrics.startEnd2End(source.getRemoteConfigName());
+        HttpResult result =
+            fetchClient.callBatchSendObject(project, filteredRefsBatch, eventCreatedOn, uri);
+        boolean resultSuccessful = result.isSuccessful();
+        repLog.info(
+            "Pull replication REST API batch apply object to {} COMPLETED for {}:[{}], HTTP  Result:"
+                + " {} - time:{} ms",
+            apiUrl,
+            project,
+            batchApplyObjectStr,
+            result,
+            apiTimer.stop() / 1000000.0);
+
+        if (!resultSuccessful
+            && result.isProjectMissing(project)
+            && source.isCreateMissingRepositories()) {
+          result = initProject(project, uri, fetchClient, result);
+          repLog.info("Missing project {} created, HTTP Result:{}", project, result);
+        }
+
+        if (!resultSuccessful && result.isParentObjectMissing()) {
+          resultSuccessful = true;
+          for (BatchApplyObjectData batchApplyObject : filteredRefsBatch) {
+            String refName = batchApplyObject.refName();
+            if ((RefNames.isNoteDbMetaRef(refName) || applyObjectsRefsFilter.match(refName))
+                && batchApplyObject.revisionData().isPresent()) {
+
+              Optional<RevisionData> maybeRevisionData = batchApplyObject.revisionData();
+              List<RevisionData> allRevisions =
+                  fetchWholeMetaHistory(project, refName, maybeRevisionData.get());
+
+              resultSuccessful &=
+                  callSendObject(
+                      source,
+                      project,
+                      refName,
+                      eventCreatedOn,
+                      batchApplyObject.isDelete(),
+                      allRevisions,
+                      state);
+            } else {
+              throw new MissingParentObjectException(
+                  project, refName, source.getRemoteConfigName());
+            }
+          }
+        }
+
+        if (!resultSuccessful && !result.isSendBatchObjectAvailable()) {
+          resultSuccessful = true;
+          for (BatchApplyObjectData batchApplyObjectData : filteredRefsBatch) {
+            resultSuccessful &=
+                callSendObject(
+                    source,
+                    project,
+                    batchApplyObjectData.refName(),
+                    eventCreatedOn,
+                    batchApplyObjectData.isDelete(),
+                    batchApplyObjectData.revisionData().map(ImmutableList::of).orElse(null),
+                    state);
+          }
+        }
+
+        batchResultSuccessful &= resultSuccessful;
+      } catch (URISyntaxException e) {
+        repLog.warn(
+            "Pull replication REST API batch apply object to {} *FAILED* for {}:[{}]",
+            apiUrl,
+            project,
+            batchApplyObjectStr,
+            e);
+        stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+        batchResultSuccessful = false;
+      } catch (IOException | IllegalArgumentException e) {
+        repLog.warn(
+            "Pull replication REST API batch apply object to {} *FAILED* for {}:[{}]",
+            apiUrl,
+            project,
+            batchApplyObjectStr,
+            e);
+        stateLog.error(
+            String.format(
+                "Exception during the pull replication fetch rest api call. Endpoint url:%s,"
+                    + " message:%s",
+                apiUrl, e.getMessage()),
+            e,
+            state);
+        batchResultSuccessful = false;
+      }
+    }
+    return batchResultSuccessful;
+  }
+
   private List<RevisionData> fetchWholeMetaHistory(
       NameKey project, String refName, RevisionData revision)
       throws RepositoryNotFoundException, MissingObjectException, IncorrectObjectTypeException,
@@ -458,52 +633,60 @@
   }
 
   private boolean callFetch(
-      Source source, Project.NameKey project, String refName, ReplicationState state) {
+      Source source,
+      Project.NameKey project,
+      List<ReferenceUpdatedEvent> refs,
+      ReplicationState state) {
     boolean resultIsSuccessful = true;
-    if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
-      for (String apiUrl : source.getApis()) {
-        try {
-          URIish uri = new URIish(apiUrl);
-          FetchApiClient fetchClient = fetchClientFactory.create(source);
-          repLog.info("Pull replication REST API fetch to {} for {}:{}", apiUrl, project, refName);
-          Context<String> timer = fetchMetrics.startEnd2End(source.getRemoteConfigName());
-          HttpResult result = fetchClient.callFetch(project, refName, uri);
-          long elapsedMs = TimeUnit.NANOSECONDS.toMillis(timer.stop());
-          boolean resultSuccessful = result.isSuccessful();
-          repLog.info(
-              "Pull replication REST API fetch to {} COMPLETED for {}:{}, HTTP Result:"
-                  + " {} - time:{} ms",
-              apiUrl,
-              project,
-              refName,
-              result,
-              elapsedMs);
-          if (!resultSuccessful
-              && result.isProjectMissing(project)
-              && source.isCreateMissingRepositories()) {
-            result = initProject(project, uri, fetchClient, result);
-          }
-          if (!resultSuccessful) {
-            stateLog.warn(
-                String.format(
-                    "Pull replication rest api fetch call failed. Endpoint url: %s, reason:%s",
-                    apiUrl, result.getMessage().orElse("unknown")),
-                state);
-          }
+    for (ReferenceUpdatedEvent refEvent : refs) {
+      String refName = refEvent.refName();
+      if (source.wouldFetchProject(project) && source.wouldFetchRef(refName)) {
+        for (String apiUrl : source.getApis()) {
+          try {
+            URIish uri = new URIish(apiUrl);
+            FetchApiClient fetchClient = fetchClientFactory.create(source);
+            repLog.info(
+                "Pull replication REST API fetch to {} for {}:{}", apiUrl, project, refName);
+            Context<String> timer = fetchMetrics.startEnd2End(source.getRemoteConfigName());
+            HttpResult result = fetchClient.callFetch(project, refName, uri);
+            long elapsedMs = TimeUnit.NANOSECONDS.toMillis(timer.stop());
+            boolean resultSuccessful = result.isSuccessful();
+            repLog.info(
+                "Pull replication REST API fetch to {} COMPLETED for {}:{}, HTTP Result:"
+                    + " {} - time:{} ms",
+                apiUrl,
+                project,
+                refName,
+                result,
+                elapsedMs);
+            if (!resultSuccessful
+                && result.isProjectMissing(project)
+                && source.isCreateMissingRepositories()) {
+              result = initProject(project, uri, fetchClient, result);
+            }
+            if (!resultSuccessful) {
+              stateLog.warn(
+                  String.format(
+                      "Pull replication rest api fetch call failed. Endpoint url: %s, reason:%s",
+                      apiUrl, result.getMessage().orElse("unknown")),
+                  state);
+            }
 
-          resultIsSuccessful &= result.isSuccessful();
-        } catch (URISyntaxException e) {
-          stateLog.error(String.format("Cannot parse pull replication api url:%s", apiUrl), state);
-          resultIsSuccessful = false;
-        } catch (Exception e) {
-          stateLog.error(
-              String.format(
-                  "Exception during the pull replication fetch rest api call. Endpoint url:%s,"
-                      + " message:%s",
-                  apiUrl, e.getMessage()),
-              e,
-              state);
-          resultIsSuccessful = false;
+            resultIsSuccessful &= result.isSuccessful();
+          } catch (URISyntaxException e) {
+            stateLog.error(
+                String.format("Cannot parse pull replication api url:%s", apiUrl), state);
+            resultIsSuccessful = false;
+          } catch (Exception e) {
+            stateLog.error(
+                String.format(
+                    "Exception during the pull replication fetch rest api call. Endpoint url:%s,"
+                        + " message:%s",
+                    apiUrl, e.getMessage()),
+                e,
+                state);
+            resultIsSuccessful = false;
+          }
         }
       }
     }
@@ -517,7 +700,7 @@
 
   private HttpResult initProject(
       Project.NameKey project, URIish uri, FetchApiClient fetchClient, HttpResult result)
-      throws IOException, ClientProtocolException {
+      throws IOException {
     HttpResult initProjectResult = fetchClient.initProject(project, uri);
     if (initProjectResult.isSuccessful()) {
       result = fetchClient.callFetch(project, FetchOne.ALL_REFS, uri);
@@ -530,8 +713,14 @@
 
   private void fireBeforeStartupEvents() {
     Set<String> eventsReplayed = new HashSet<>();
-    for (ReferenceUpdatedEvent event : beforeStartupEventsQueue) {
-      String eventKey = String.format("%s:%s", event.projectName(), event.refName());
+    for (ReferenceBatchUpdatedEvent event : beforeStartupEventsQueue) {
+      String eventKey =
+          String.format(
+              "%s:%s",
+              event.projectName(),
+              event.refs().stream()
+                  .map(ReferenceUpdatedEvent::refName)
+                  .collect(Collectors.joining()));
       if (!eventsReplayed.contains(eventKey)) {
         repLog.info("Firing pending task {}", event);
         fire(event);
@@ -556,6 +745,22 @@
   }
 
   @AutoValue
+  abstract static class ReferenceBatchUpdatedEvent {
+
+    static ReferenceBatchUpdatedEvent create(
+        String projectName, List<ReferenceUpdatedEvent> refs, long eventCreatedOn) {
+      return new AutoValue_ReplicationQueue_ReferenceBatchUpdatedEvent(
+          projectName, refs, eventCreatedOn);
+    }
+
+    public abstract String projectName();
+
+    public abstract List<ReferenceUpdatedEvent> refs();
+
+    public abstract long eventCreatedOn();
+  }
+
+  @AutoValue
   abstract static class ReferenceUpdatedEvent {
 
     static ReferenceUpdatedEvent create(
@@ -568,13 +773,13 @@
           projectName, refName, objectId, eventCreatedOn, isDelete);
     }
 
-    static ReferenceUpdatedEvent from(RefUpdatedEvent event) {
+    static ReferenceUpdatedEvent from(RefUpdateAttribute refUpdateAttribute, long eventCreatedOn) {
       return ReferenceUpdatedEvent.create(
-          event.refUpdate.get().project,
-          event.getRefName(),
-          ObjectId.fromString(event.refUpdate.get().newRev),
-          event.eventCreatedOn,
-          ZEROS_OBJECTID.equals(event.refUpdate.get().newRev));
+          refUpdateAttribute.project,
+          refUpdateAttribute.refName,
+          ObjectId.fromString(refUpdateAttribute.newRev),
+          eventCreatedOn,
+          ZEROS_OBJECTID.equals(refUpdateAttribute.newRev));
     }
 
     public abstract String projectName();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
index 968a03c..a0fae22 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommand.java
@@ -23,6 +23,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -84,7 +85,8 @@
       RevisionData revisionsData,
       String sourceLabel,
       long eventCreatedOn)
-      throws IOException, RefUpdateException, MissingParentObjectException {
+      throws IOException, RefUpdateException, MissingParentObjectException,
+          ResourceNotFoundException {
     applyObjects(name, refName, new RevisionData[] {revisionsData}, sourceLabel, eventCreatedOn);
   }
 
@@ -94,7 +96,8 @@
       RevisionData[] revisionsData,
       String sourceLabel,
       long eventCreatedOn)
-      throws IOException, RefUpdateException, MissingParentObjectException {
+      throws IOException, RefUpdateException, MissingParentObjectException,
+          ResourceNotFoundException {
 
     repLog.info(
         "Apply object from {} for {}:{} - {}",
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectAction.java
new file mode 100644
index 0000000..c67ceec
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectAction.java
@@ -0,0 +1,49 @@
+// 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.pull.api;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+class BatchApplyObjectAction implements RestModifyView<ProjectResource, List<RevisionInput>> {
+
+  private final ApplyObjectAction applyObjectAction;
+
+  @Inject
+  BatchApplyObjectAction(ApplyObjectAction applyObjectAction) {
+    this.applyObjectAction = applyObjectAction;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource resource, List<RevisionInput> inputs)
+      throws RestApiException {
+
+    List<Response<?>> allResponses = new ArrayList<>();
+    for (RevisionInput input : inputs) {
+      Response<?> individualResponse = applyObjectAction.apply(resource, input);
+      allResponses.add(individualResponse);
+    }
+
+    return Response.ok(allResponses);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java
index be71946..b6b8fd0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilter.java
@@ -125,6 +125,7 @@
     return (requestURI.contains(pluginName)
             && (requestURI.endsWith(String.format("/%s~apply-object", pluginName))
                 || requestURI.endsWith(String.format("/%s~apply-objects", pluginName))
+                || requestURI.endsWith(String.format("/%s~batch-apply-object", pluginName))
                 || requestURI.endsWith(String.format("/%s~fetch", pluginName))
                 || requestURI.endsWith(String.format("/%s~delete-project", pluginName))
                 || requestURI.contains(String.format("/%s/init-project/", pluginName))))
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
index adb333c..6a21104 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
+import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
+
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,9 +30,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.replication.LocalFS;
+import com.googlesource.gerrit.plugins.deleteproject.cache.CacheDeleteHandler;
+import com.googlesource.gerrit.plugins.deleteproject.fs.RepositoryDelete;
 import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps;
+import java.io.IOException;
 import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.transport.URIish;
 
 @Singleton
@@ -44,15 +49,21 @@
   private final Provider<CurrentUser> userProvider;
   private final GerritConfigOps gerritConfigOps;
   private final PermissionBackend permissionBackend;
+  private final RepositoryDelete repositoryDelete;
+  private final CacheDeleteHandler cacheDeleteHandler;
 
   @Inject
   ProjectDeletionAction(
       GerritConfigOps gerritConfigOps,
       PermissionBackend permissionBackend,
-      Provider<CurrentUser> userProvider) {
+      Provider<CurrentUser> userProvider,
+      RepositoryDelete repositoryDelete,
+      CacheDeleteHandler cacheDeleteHandler) {
     this.gerritConfigOps = gerritConfigOps;
     this.permissionBackend = permissionBackend;
     this.userProvider = userProvider;
+    this.repositoryDelete = repositoryDelete;
+    this.cacheDeleteHandler = cacheDeleteHandler;
   }
 
   @Override
@@ -69,11 +80,24 @@
         gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName()));
 
     if (maybeRepoURI.isPresent()) {
-      if (new LocalFS(maybeRepoURI.get()).deleteProject(projectResource.getNameKey())) {
+      try {
+        // reuse repo deletion logic from delete-project plugin, as it can successfully delete
+        // the git directories hosted on nfs.
+        repositoryDelete.execute(projectResource.getNameKey());
+        // delete the project from the local project cache, otherwise future ops
+        // will fail as the replica will think that the project still exists locally.
+        cacheDeleteHandler.delete(projectResource.getProjectState().getProject());
+        repLog.info(
+            "Deleted local repository {} and removed it from the local project cache",
+            projectResource.getName());
         return Response.ok();
+      } catch (RepositoryNotFoundException e) {
+        throw new ResourceNotFoundException(
+            String.format("Repository %s not found", projectResource.getName()), e);
+      } catch (IOException e) {
+        throw new UnprocessableEntityException(
+            String.format("Could not delete project %s", projectResource.getName()));
       }
-      throw new UnprocessableEntityException(
-          String.format("Could not delete project %s", projectResource.getName()));
     }
     throw new ResourceNotFoundException(
         String.format("Could not compute URI for repo: %s", projectResource.getName()));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
index 8711379..47a75ec 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -22,11 +22,11 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,18 +50,18 @@
   private final GerritConfigOps gerritConfigOps;
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
-  private final ProjectIndexer projectIndexer;
+  private final ProjectCache projectCache;
 
   @Inject
   ProjectInitializationAction(
       GerritConfigOps gerritConfigOps,
       Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend,
-      ProjectIndexer projectIndexer) {
+      ProjectCache projectCache) {
     this.gerritConfigOps = gerritConfigOps;
     this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
-    this.projectIndexer = projectIndexer;
+    this.projectCache = projectCache;
   }
 
   @Override
@@ -111,7 +111,7 @@
     LocalFS localFS = new LocalFS(maybeUri.get());
     Project.NameKey projectNameKey = Project.NameKey.parse(projectName);
     if (localFS.createProject(projectNameKey, RefNames.HEAD)) {
-      projectIndexer.index(projectNameKey);
+      projectCache.evictAndReindex(projectNameKey);
       return true;
     }
     return false;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
index fc97945..253af58 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationEndpoints.java
@@ -33,6 +33,8 @@
   @UsedAt(PLUGIN_MULTI_SITE)
   public static final String APPLY_OBJECTS_API_ENDPOINT = "apply-objects";
 
+  public static final String BATCH_APPLY_OBJECT_API_ENDPOINT = "batch-apply-object";
+
   public static final String FETCH_ENDPOINT = "fetch";
   public static final String INIT_PROJECT_ENDPOINT = "init-project";
   public static final String DELETE_PROJECT_ENDPOINT = "delete-project";
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
index 40e39ad..bfe51a2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -22,10 +22,12 @@
 import static javax.servlet.http.HttpServletResponse.SC_CREATED;
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.common.reflect.TypeToken;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.api.projects.HeadInput;
@@ -62,6 +64,8 @@
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.lang.reflect.Type;
+import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Optional;
@@ -84,6 +88,7 @@
   private FetchAction fetchAction;
   private ApplyObjectAction applyObjectAction;
   private ApplyObjectsAction applyObjectsAction;
+  private BatchApplyObjectAction batchApplyObjectAction;
   private ProjectInitializationAction projectInitializationAction;
   private UpdateHeadAction updateHEADAction;
   private ProjectDeletionAction projectDeletionAction;
@@ -97,6 +102,7 @@
       FetchAction fetchAction,
       ApplyObjectAction applyObjectAction,
       ApplyObjectsAction applyObjectsAction,
+      BatchApplyObjectAction batchApplyObjectAction,
       ProjectInitializationAction projectInitializationAction,
       UpdateHeadAction updateHEADAction,
       ProjectDeletionAction projectDeletionAction,
@@ -106,6 +112,7 @@
     this.fetchAction = fetchAction;
     this.applyObjectAction = applyObjectAction;
     this.applyObjectsAction = applyObjectsAction;
+    this.batchApplyObjectAction = batchApplyObjectAction;
     this.projectInitializationAction = projectInitializationAction;
     this.updateHEADAction = updateHEADAction;
     this.projectDeletionAction = projectDeletionAction;
@@ -135,6 +142,9 @@
       } else if (isApplyObjectsAction(httpRequest)) {
         failIfcurrentUserIsAnonymous();
         writeResponse(httpResponse, doApplyObjects(httpRequest));
+      } else if (isBatchApplyObjectsAction(httpRequest)) {
+        failIfcurrentUserIsAnonymous();
+        writeResponse(httpResponse, doBatchApplyObject(httpRequest));
       } else if (isInitProjectAction(httpRequest)) {
         failIfcurrentUserIsAnonymous();
         if (!checkAcceptHeader(httpRequest, httpResponse)) {
@@ -169,9 +179,12 @@
     } catch (ResourceConflictException e) {
       RestApiServlet.replyError(
           httpRequest, httpResponse, SC_CONFLICT, e.getMessage(), e.caching(), e);
-    } catch (InitProjectException | ResourceNotFoundException e) {
+    } catch (InitProjectException e) {
       RestApiServlet.replyError(
           httpRequest, httpResponse, SC_INTERNAL_SERVER_ERROR, e.getMessage(), e.caching(), e);
+    } catch (ResourceNotFoundException e) {
+      RestApiServlet.replyError(
+          httpRequest, httpResponse, SC_NOT_FOUND, e.getMessage(), e.caching(), e);
     } catch (NoSuchElementException e) {
       RestApiServlet.replyError(
           httpRequest, httpResponse, SC_BAD_REQUEST, "Project name not present in the url", e);
@@ -209,7 +222,7 @@
   @SuppressWarnings("unchecked")
   private Response<String> doApplyObject(HttpServletRequest httpRequest)
       throws RestApiException, IOException, PermissionBackendException {
-    RevisionInput input = readJson(httpRequest, TypeLiteral.get(RevisionInput.class));
+    RevisionInput input = readJson(httpRequest, TypeLiteral.get(RevisionInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) applyObjectAction.apply(parseProjectResource(id), input);
@@ -218,15 +231,26 @@
   @SuppressWarnings("unchecked")
   private Response<String> doApplyObjects(HttpServletRequest httpRequest)
       throws RestApiException, IOException, PermissionBackendException {
-    RevisionsInput input = readJson(httpRequest, TypeLiteral.get(RevisionsInput.class));
+    RevisionsInput input = readJson(httpRequest, TypeLiteral.get(RevisionsInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) applyObjectsAction.apply(parseProjectResource(id), input);
   }
 
   @SuppressWarnings("unchecked")
+  private Response<Map<String, Object>> doBatchApplyObject(HttpServletRequest httpRequest)
+      throws RestApiException, IOException, PermissionBackendException {
+    TypeToken<List<RevisionInput>> collectionType = new TypeToken<>() {};
+    List<RevisionInput> inputs = readJson(httpRequest, collectionType.getType());
+    IdString id = getProjectName(httpRequest).get();
+
+    return (Response<Map<String, Object>>)
+        batchApplyObjectAction.apply(parseProjectResource(id), inputs);
+  }
+
+  @SuppressWarnings("unchecked")
   private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception {
-    HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class));
+    HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<String>) updateHEADAction.apply(parseProjectResource(id), input);
@@ -243,7 +267,7 @@
   @SuppressWarnings("unchecked")
   private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest)
       throws IOException, RestApiException, PermissionBackendException {
-    Input input = readJson(httpRequest, TypeLiteral.get(Input.class));
+    Input input = readJson(httpRequest, TypeLiteral.get(Input.class).getType());
     IdString id = getProjectName(httpRequest).get();
 
     return (Response<Map<String, Object>>) fetchAction.apply(parseProjectResource(id), input);
@@ -272,7 +296,7 @@
     }
   }
 
-  private <T> T readJson(HttpServletRequest httpRequest, TypeLiteral<T> typeLiteral)
+  private <T> T readJson(HttpServletRequest httpRequest, Type typeToken)
       throws IOException, BadRequestException {
 
     try (BufferedReader br = httpRequest.getReader();
@@ -286,7 +310,7 @@
           throw new BadRequestException("Expected JSON object", e);
         }
 
-        return gson.fromJson(json, typeLiteral.getType());
+        return gson.fromJson(json, typeToken);
       } finally {
         try {
           // Reader.close won't consume the rest of the input. Explicitly consume the request
@@ -339,6 +363,12 @@
         .endsWith(String.format("/%s~" + APPLY_OBJECTS_API_ENDPOINT, pluginName));
   }
 
+  private boolean isBatchApplyObjectsAction(HttpServletRequest httpRequest) {
+    return httpRequest
+        .getRequestURI()
+        .endsWith(String.format("/%s~" + BATCH_APPLY_OBJECT_API_ENDPOINT, pluginName));
+  }
+
   private boolean isFetchAction(HttpServletRequest httpRequest) {
     return httpRequest.getRequestURI().endsWith(String.format("/%s~" + FETCH_ENDPOINT, pluginName));
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectData.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectData.java
new file mode 100644
index 0000000..7a613c0
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectData.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.pull.api.data;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+@AutoValue
+public abstract class BatchApplyObjectData {
+
+  public static BatchApplyObjectData create(
+      String refName, Optional<RevisionData> revisionData, boolean isDelete)
+      throws IllegalArgumentException {
+    if (isDelete && revisionData.isPresent()) {
+      throw new IllegalArgumentException(
+          "DELETE ref-updates cannot be associated with a RevisionData");
+    }
+    return new AutoValue_BatchApplyObjectData(refName, revisionData, isDelete);
+  }
+
+  public abstract String refName();
+
+  public abstract Optional<RevisionData> revisionData();
+
+  public abstract boolean isDelete();
+
+  @Override
+  public String toString() {
+    return String.format(
+        "%s:%s isDelete=%s",
+        refName(), revisionData().map(RevisionData::toString).orElse("ABSENT"), isDelete());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
index 1991260..5f39811 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchApiClient.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.BatchApplyObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import java.io.IOException;
 import java.util.List;
-import org.apache.http.client.ClientProtocolException;
 import org.eclipse.jgit.transport.URIish;
 
 public interface FetchApiClient {
@@ -33,10 +33,10 @@
 
   HttpResult callFetch(
       Project.NameKey project, String refName, URIish targetUri, long startTimeNanos)
-      throws ClientProtocolException, IOException;
+      throws IOException;
 
   default HttpResult callFetch(Project.NameKey project, String refName, URIish targetUri)
-      throws ClientProtocolException, IOException {
+      throws IOException {
     return callFetch(project, refName, targetUri, MILLISECONDS.toNanos(System.currentTimeMillis()));
   }
 
@@ -53,7 +53,14 @@
       boolean isDelete,
       RevisionData revisionData,
       URIish targetUri)
-      throws ClientProtocolException, IOException;
+      throws IOException;
+
+  HttpResult callBatchSendObject(
+      NameKey project,
+      List<BatchApplyObjectData> batchApplyObjects,
+      long eventCreatedOn,
+      URIish targetUri)
+      throws IOException;
 
   HttpResult callSendObjects(
       NameKey project,
@@ -61,5 +68,5 @@
       long eventCreatedOn,
       List<RevisionData> revisionData,
       URIish targetUri)
-      throws ClientProtocolException, IOException;
+      throws IOException;
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
index b606ba8..2b9ef09 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
 import static java.util.Objects.requireNonNull;
 
@@ -35,6 +36,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.BearerTokenProvider;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
 import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.BatchApplyObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionsInput;
@@ -43,12 +45,12 @@
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import org.apache.http.HttpHeaders;
 import org.apache.http.HttpResponse;
 import org.apache.http.ParseException;
 import org.apache.http.auth.AuthenticationException;
 import org.apache.http.auth.UsernamePasswordCredentials;
-import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.ResponseHandler;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
@@ -122,7 +124,7 @@
                 "{\"label\":\"%s\", \"ref_name\": \"%s\", \"async\":%s}",
                 instanceId, refName, callAsync),
             StandardCharsets.UTF_8));
-    post.addHeader(new BasicHeader("Content-Type", "application/json"));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
     post.addHeader(
         PullReplicationApiRequestMetrics.HTTP_HEADER_X_START_TIME_NANOS,
         Long.toString(startTimeNanos));
@@ -137,7 +139,7 @@
     String url = formatInitProjectUrl(uri.toString(), project);
     HttpPut put = new HttpPut(url);
     put.addHeader(new BasicHeader("Accept", MediaType.ANY_TEXT_TYPE.toString()));
-    put.addHeader(new BasicHeader("Content-Type", MediaType.PLAIN_TEXT_UTF_8.toString()));
+    put.addHeader(new BasicHeader(CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8.toString()));
     return executeRequest(put, bearerTokenProvider.get(), uri);
   }
 
@@ -162,7 +164,7 @@
     HttpPut req = new HttpPut(url);
     req.setEntity(
         new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), StandardCharsets.UTF_8));
-    req.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
+    req.addHeader(new BasicHeader(CONTENT_TYPE, MediaType.JSON_UTF_8.toString()));
     return executeRequest(req, bearerTokenProvider.get(), apiUri);
   }
 
@@ -177,7 +179,7 @@
       boolean isDelete,
       @Nullable RevisionData revisionData,
       URIish targetUri)
-      throws ClientProtocolException, IOException {
+      throws IOException {
 
     if (!isDelete) {
       requireNonNull(
@@ -191,7 +193,33 @@
 
     HttpPost post = new HttpPost(url);
     post.setEntity(new StringEntity(GSON.toJson(input)));
-    post.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, MediaType.JSON_UTF_8.toString()));
+    return executeRequest(post, bearerTokenProvider.get(), targetUri);
+  }
+
+  @Override
+  public HttpResult callBatchSendObject(
+      NameKey project,
+      List<BatchApplyObjectData> batchedRefs,
+      long eventCreatedOn,
+      URIish targetUri)
+      throws IOException {
+    List<RevisionInput> inputs =
+        batchedRefs.stream()
+            .map(
+                batchApplyObject ->
+                    new RevisionInput(
+                        instanceId,
+                        batchApplyObject.refName(),
+                        eventCreatedOn,
+                        batchApplyObject.revisionData().orElse(null)))
+            .collect(Collectors.toList());
+
+    String url = formatUrl(targetUri.toString(), project, "batch-apply-object");
+
+    HttpPost post = new HttpPost(url);
+    post.setEntity(new StringEntity(GSON.toJson(inputs)));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, MediaType.JSON_UTF_8.toString()));
     return executeRequest(post, bearerTokenProvider.get(), targetUri);
   }
 
@@ -202,7 +230,7 @@
       long eventCreatedOn,
       List<RevisionData> revisionData,
       URIish targetUri)
-      throws ClientProtocolException, IOException {
+      throws IOException {
     if (revisionData.size() == 1) {
       return callSendObject(
           project, refName, eventCreatedOn, false, revisionData.get(0), targetUri);
@@ -215,7 +243,7 @@
     String url = formatUrl(targetUri.toString(), project, "apply-objects");
     HttpPost post = new HttpPost(url);
     post.setEntity(new StringEntity(GSON.toJson(input)));
-    post.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString()));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, MediaType.JSON_UTF_8.toString()));
     return executeRequest(post, bearerTokenProvider.get(), targetUri);
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
index ec9d65f..6428ece 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResult.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.replication.pull.client;
 
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.gerrit.entities.Project;
 import java.util.Optional;
@@ -51,4 +52,8 @@
         ? "OK"
         : "FAILED" + ", status=" + responseCode + message.map(s -> " '" + s + "'").orElse("");
   }
+
+  public boolean isSendBatchObjectAvailable() {
+    return responseCode != SC_NOT_FOUND;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java
deleted file mode 100644
index a618e16..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandler.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.plugins.replication.pull.event;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.EventListener;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.replication.pull.Context;
-import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
-import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
-
-public class FetchRefReplicatedEventHandler implements EventListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private ChangeIndexer changeIndexer;
-
-  @Inject
-  FetchRefReplicatedEventHandler(ChangeIndexer changeIndexer) {
-    this.changeIndexer = changeIndexer;
-  }
-
-  @Override
-  public void onEvent(Event event) {
-    if (event instanceof FetchRefReplicatedEvent && isLocalEvent()) {
-      FetchRefReplicatedEvent fetchRefReplicatedEvent = (FetchRefReplicatedEvent) event;
-      if (!RefNames.isNoteDbMetaRef(fetchRefReplicatedEvent.getRefName())
-          || !fetchRefReplicatedEvent
-              .getStatus()
-              .equals(ReplicationState.RefFetchResult.SUCCEEDED.toString())) {
-        return;
-      }
-
-      Project.NameKey projectNameKey = fetchRefReplicatedEvent.getProjectNameKey();
-      logger.atFine().log(
-          "Indexing ref '%s' for project %s",
-          fetchRefReplicatedEvent.getRefName(), projectNameKey.get());
-      Change.Id changeId = Change.Id.fromRef(fetchRefReplicatedEvent.getRefName());
-      if (changeId != null) {
-        changeIndexer.index(projectNameKey, changeId);
-      } else {
-        logger.atWarning().log(
-            "Couldn't get changeId from refName. Skipping indexing of change %s for project %s",
-            fetchRefReplicatedEvent.getRefName(), projectNameKey.get());
-      }
-    }
-  }
-
-  private boolean isLocalEvent() {
-    return Context.isLocalEvent();
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java
deleted file mode 100644
index 675563a..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventModule.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.plugins.replication.pull.event;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.events.EventListener;
-
-public class FetchRefReplicatedEventModule extends LifecycleModule {
-
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), EventListener.class).to(FetchRefReplicatedEventHandler.class);
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
index 36356e9..3b6b0be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObject.java
@@ -15,6 +15,8 @@
 package com.googlesource.gerrit.plugins.replication.pull.fetch;
 
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.pull.LocalGitRepositoryManagerProvider;
@@ -22,6 +24,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
 import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -43,7 +46,7 @@
   }
 
   public RefUpdateState apply(Project.NameKey name, RefSpec refSpec, RevisionData[] revisionsData)
-      throws MissingParentObjectException, IOException {
+      throws MissingParentObjectException, IOException, ResourceNotFoundException {
     try (Repository git = gitManager.openRepository(name)) {
 
       ObjectId refHead = null;
@@ -87,6 +90,8 @@
         RefUpdate.Result result = ru.update();
         return new RefUpdateState(refSpec.getSource(), result);
       }
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(IdString.fromDecoded(name.get()));
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeGitReferenceUpdatedEvent.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeGitReferenceUpdatedEvent.java
deleted file mode 100644
index 1472be2..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeGitReferenceUpdatedEvent.java
+++ /dev/null
@@ -1,37 +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.pull;
-
-import com.google.common.base.Suppliers;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.data.RefUpdateAttribute;
-import com.google.gerrit.server.events.RefUpdatedEvent;
-
-public class FakeGitReferenceUpdatedEvent extends RefUpdatedEvent {
-  FakeGitReferenceUpdatedEvent(
-      Project.NameKey project,
-      String ref,
-      String oldObjectId,
-      String newObjectId,
-      String instanceId) {
-    RefUpdateAttribute upd = new RefUpdateAttribute();
-    upd.newRev = newObjectId;
-    upd.oldRev = oldObjectId;
-    upd.project = project.get();
-    upd.refName = ref;
-    this.refUpdate = Suppliers.ofInstance(upd);
-    this.instanceId = instanceId;
-  }
-}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedAsyncIT.java
similarity index 90%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedAsyncIT.java
index 0ebd0ae..a72a97e 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedAsyncIT.java
@@ -29,10 +29,15 @@
     sysModule =
         "com.googlesource.gerrit.plugins.replication.pull.PullReplicationITAbstract$PullReplicationTestModule",
     httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
-public class PullReplicationAsyncIT extends PullReplicationITAbstract {
+public class PullReplicationBatchRefUpdatedAsyncIT extends PullReplicationITAbstract {
   @Inject private SitePaths sitePaths;
 
   @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return true;
+  }
+
+  @Override
   public void setUpTestPlugin() throws Exception {
     FileBasedConfig config =
         new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedIT.java
new file mode 100644
index 0000000..2e727d1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationBatchRefUpdatedIT.java
@@ -0,0 +1,33 @@
+// 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationBatchRefUpdatedIT extends PullReplicationITBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return true;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBase.java
similarity index 74%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigIT.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBase.java
index 4ede1ae..4349cde 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBase.java
@@ -17,27 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.SkipProjectClone;
-import com.google.gerrit.acceptance.TestPlugin;
-import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInput;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
+import com.google.gerrit.server.events.ProjectEvent;
 import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator;
 import java.io.IOException;
 import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.Duration;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import java.util.function.Supplier;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -46,33 +35,14 @@
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
 
-@SkipProjectClone
-@UseLocalDisk
-@TestPlugin(
-    name = "pull-replication",
-    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
-    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
-public class PullReplicationFanoutConfigIT extends LightweightPluginDaemonTest {
-  private static final Optional<String> ALL_PROJECTS = Optional.empty();
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+abstract class PullReplicationFanoutConfigBase extends PullReplicationSetupBase {
   private static final int TEST_REPLICATION_DELAY = 60;
-  private static final Duration TEST_TIMEOUT = Duration.ofSeconds(TEST_REPLICATION_DELAY * 2);
-  private static final String TEST_REPLICATION_SUFFIX = "suffix1";
-  private static final String TEST_REPLICATION_REMOTE = "remote1";
 
-  @Inject private SitePaths sitePaths;
-  @Inject private ProjectOperations projectOperations;
-  private Path gitPath;
-  private FileBasedConfig config;
   private FileBasedConfig remoteConfig;
-  private FileBasedConfig secureConfig;
 
   @Override
   public void setUpTestPlugin() throws Exception {
     gitPath = sitePaths.site_path.resolve("git");
-
-    config =
-        new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
     remoteConfig =
         new FileBasedConfig(
             sitePaths
@@ -81,17 +51,8 @@
                 .toFile(),
             FS.DETECTED);
 
-    setReplicationSource(
-        TEST_REPLICATION_REMOTE); // Simulates a full replication.config initialization
-
     setRemoteConfig(TEST_REPLICATION_SUFFIX, ALL_PROJECTS);
-
-    secureConfig =
-        new FileBasedConfig(sitePaths.etc_dir.resolve("secure.config").toFile(), FS.DETECTED);
-    setReplicationCredentials(TEST_REPLICATION_REMOTE, admin.username(), admin.httpPassword());
-    secureConfig.save();
-
-    super.setUpTestPlugin();
+    super.setUpTestPlugin(false);
   }
 
   @Test
@@ -104,8 +65,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -140,8 +101,8 @@
     RevCommit sourceCommit = pushResult.getCommit();
     final String sourceRef = pushResult.getPatchSet().refName();
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -173,8 +134,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -251,17 +212,11 @@
     waitUntil(() -> sources.getAll().size() == 1);
   }
 
-  private Ref getRef(Repository repo, String branchName) throws IOException {
-    return repo.getRefDatabase().exactRef(branchName);
-  }
-
-  private Ref checkedGetRef(Repository repo, String branchName) {
-    try {
-      return repo.getRefDatabase().exactRef(branchName);
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("failed to get ref %s in repo %s", branchName, repo);
-      return null;
-    }
+  @Override
+  protected void setReplicationSource(
+      String remoteName, List<String> replicaSuffixes, Optional<String> project)
+      throws IOException {
+    setReplicationSource(remoteName);
   }
 
   private void setReplicationSource(String remoteName) throws IOException {
@@ -294,23 +249,4 @@
     project.ifPresent(prj -> remoteConfig.setString("remote", null, "projects", prj));
     remoteConfig.save();
   }
-
-  private void setReplicationCredentials(String remoteName, String username, String password)
-      throws IOException {
-    secureConfig.setString("remote", remoteName, "username", username);
-    secureConfig.setString("remote", remoteName, "password", password);
-    secureConfig.save();
-  }
-
-  private void waitUntil(Supplier<Boolean> waitCondition) throws InterruptedException {
-    WaitUtil.waitUntil(waitCondition, TEST_TIMEOUT);
-  }
-
-  private <T> T getInstance(Class<T> classObj) {
-    return plugin.getSysInjector().getInstance(classObj);
-  }
-
-  private Project.NameKey createTestProject(String name) throws Exception {
-    return projectOperations.newProject().name(name).create();
-  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBatchRefUpdateEventIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBatchRefUpdateEventIT.java
new file mode 100644
index 0000000..7babf34
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigBatchRefUpdateEventIT.java
@@ -0,0 +1,33 @@
+// 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationFanoutConfigBatchRefUpdateEventIT
+    extends PullReplicationFanoutConfigBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return true;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigRefUpdatedEventIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigRefUpdatedEventIT.java
new file mode 100644
index 0000000..16b0b02
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationFanoutConfigRefUpdatedEventIT.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.replication.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationFanoutConfigRefUpdatedEventIT extends PullReplicationFanoutConfigBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return false;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITAbstract.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITAbstract.java
index 58693bc..c601e52 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITAbstract.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITAbstract.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.events.ProjectEvent;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator;
 import java.io.IOException;
@@ -93,8 +94,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -134,8 +135,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -180,8 +181,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -207,8 +208,8 @@
     assertThat(pushedRefs).hasSize(1);
     assertThat(pushedRefs.iterator().next().getStatus()).isEqualTo(Status.OK);
 
-    FakeGitReferenceUpdatedEvent forcedPushEvent =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent forcedPushEvent =
+        generateUpdateEvent(
             project,
             newBranch,
             branchRevision,
@@ -249,8 +250,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -293,8 +294,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -394,8 +395,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITBase.java
similarity index 93%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITBase.java
index 29bf7e4..b1e140c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationITBase.java
@@ -21,8 +21,6 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.SkipProjectClone;
-import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.Permission;
@@ -34,6 +32,7 @@
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.events.ProjectEvent;
 import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient;
 import java.io.IOException;
@@ -54,14 +53,7 @@
 import org.junit.Ignore;
 import org.junit.Test;
 
-@SkipProjectClone
-@UseLocalDisk
-@TestPlugin(
-    name = "pull-replication",
-    sysModule =
-        "com.googlesource.gerrit.plugins.replication.pull.PullReplicationITAbstract$PullReplicationTestModule",
-    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
-public class PullReplicationIT extends PullReplicationSetupBase {
+abstract class PullReplicationITBase extends PullReplicationSetupBase {
 
   @Override
   protected void setReplicationSource(
@@ -94,8 +86,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -127,8 +119,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -170,8 +162,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -196,8 +188,8 @@
     assertThat(pushedRefs).hasSize(1);
     assertThat(pushedRefs.iterator().next().getStatus()).isEqualTo(Status.OK);
 
-    FakeGitReferenceUpdatedEvent forcedPushEvent =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent forcedPushEvent =
+        generateUpdateEvent(
             project,
             newBranch,
             branchRevision,
@@ -235,8 +227,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
@@ -276,8 +268,8 @@
 
     ReplicationQueue pullReplicationQueue =
         plugin.getSysInjector().getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             newBranch,
             ObjectId.zeroId().getName(),
@@ -386,8 +378,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedAsyncIT.java
similarity index 90%
copy from src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
copy to src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedAsyncIT.java
index 0ebd0ae..fba8783 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationAsyncIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedAsyncIT.java
@@ -29,10 +29,15 @@
     sysModule =
         "com.googlesource.gerrit.plugins.replication.pull.PullReplicationITAbstract$PullReplicationTestModule",
     httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
-public class PullReplicationAsyncIT extends PullReplicationITAbstract {
+public class PullReplicationRefUpdatedAsyncIT extends PullReplicationITAbstract {
   @Inject private SitePaths sitePaths;
 
   @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return false;
+  }
+
+  @Override
   public void setUpTestPlugin() throws Exception {
     FileBasedConfig config =
         new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedIT.java
new file mode 100644
index 0000000..6e7c369
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationRefUpdatedIT.java
@@ -0,0 +1,33 @@
+// 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationRefUpdatedIT extends PullReplicationITBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return false;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationSetupBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationSetupBase.java
index e07d481..1ef4d35 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationSetupBase.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationSetupBase.java
@@ -16,13 +16,20 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Suppliers;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 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.data.AccountAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.BatchRefUpdateEvent;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
@@ -54,6 +61,22 @@
   protected FileBasedConfig config;
   protected FileBasedConfig secureConfig;
 
+  protected abstract boolean useBatchRefUpdateEvent();
+
+  protected ProjectEvent generateUpdateEvent(
+      Project.NameKey project,
+      String ref,
+      String oldObjectId,
+      String newObjectId,
+      String instanceId) {
+
+    if (useBatchRefUpdateEvent()) {
+      return generateBatchRefUpdateEvent(project, ref, oldObjectId, newObjectId, instanceId);
+    }
+
+    return generateRefUpdateEvent(project, ref, oldObjectId, newObjectId, instanceId);
+  }
+
   protected void setUpTestPlugin(boolean loadExisting) throws Exception {
     gitPath = sitePaths.site_path.resolve("git");
 
@@ -73,6 +96,9 @@
     setReplicationCredentials(TEST_REPLICATION_REMOTE, admin.username(), admin.httpPassword());
     secureConfig.save();
 
+    cfg.setBoolean(
+        "event", "stream-events", "enableBatchRefUpdatedEvents", useBatchRefUpdateEvent());
+
     super.setUpTestPlugin();
   }
 
@@ -121,4 +147,38 @@
       List<String> replicaSuffixes, Function<String, String> toURL) {
     return replicaSuffixes.stream().map(suffix -> toURL.apply(suffix)).collect(toList());
   }
+
+  private BatchRefUpdateEvent generateBatchRefUpdateEvent(
+      Project.NameKey project,
+      String ref,
+      String oldObjectId,
+      String newObjectId,
+      String instanceId) {
+    RefUpdateAttribute upd = new RefUpdateAttribute();
+    upd.newRev = newObjectId;
+    upd.oldRev = oldObjectId;
+    upd.project = project.get();
+    upd.refName = ref;
+    BatchRefUpdateEvent event =
+        new BatchRefUpdateEvent(
+            project,
+            Suppliers.ofInstance(List.of(upd)),
+            Suppliers.ofInstance(new AccountAttribute(admin.id().get())));
+    event.instanceId = instanceId;
+    return event;
+  }
+
+  private ProjectEvent generateRefUpdateEvent(
+      NameKey project, String ref, String oldObjectId, String newObjectId, String instanceId) {
+    RefUpdateAttribute upd = new RefUpdateAttribute();
+    upd.newRev = newObjectId;
+    upd.oldRev = oldObjectId;
+    upd.project = project.get();
+    upd.refName = ref;
+    RefUpdatedEvent event = new RefUpdatedEvent();
+    event.refUpdate = Suppliers.ofInstance(upd);
+    event.submitter = Suppliers.ofInstance(new AccountAttribute(admin.id().get()));
+    event.instanceId = instanceId;
+    return event;
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBase.java
similarity index 94%
rename from src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolIT.java
rename to src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBase.java
index e55e383..2e95ef1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBase.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.events.ProjectEvent;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
@@ -36,7 +37,8 @@
     name = "pull-replication",
     sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
     httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
-public class PullReplicationWithGitHttpTransportProtocolIT extends PullReplicationSetupBase {
+public abstract class PullReplicationWithGitHttpTransportProtocolBase
+    extends PullReplicationSetupBase {
 
   @Override
   protected void setReplicationSource(
@@ -80,8 +82,8 @@
     String sourceRef = pushResult.getPatchSet().refName();
 
     ReplicationQueue pullReplicationQueue = getInstance(ReplicationQueue.class);
-    FakeGitReferenceUpdatedEvent event =
-        new FakeGitReferenceUpdatedEvent(
+    ProjectEvent event =
+        generateUpdateEvent(
             project,
             sourceRef,
             ObjectId.zeroId().getName(),
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBatchRefUpdatedIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBatchRefUpdatedIT.java
new file mode 100644
index 0000000..8c8ca37
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolBatchRefUpdatedIT.java
@@ -0,0 +1,34 @@
+// 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationWithGitHttpTransportProtocolBatchRefUpdatedIT
+    extends PullReplicationWithGitHttpTransportProtocolBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return true;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolRefUpdatedIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolRefUpdatedIT.java
new file mode 100644
index 0000000..5f3c7b6
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationWithGitHttpTransportProtocolRefUpdatedIT.java
@@ -0,0 +1,34 @@
+// 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.pull;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+
+@SkipProjectClone
+@UseLocalDisk
+@TestPlugin(
+    name = "pull-replication",
+    sysModule = "com.googlesource.gerrit.plugins.replication.pull.PullReplicationModule",
+    httpModule = "com.googlesource.gerrit.plugins.replication.pull.api.HttpModule")
+public class PullReplicationWithGitHttpTransportProtocolRefUpdatedIT
+    extends PullReplicationWithGitHttpTransportProtocolBase {
+
+  @Override
+  protected boolean useBatchRefUpdateEvent() {
+    return false;
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
index e40b90c..53997b7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
@@ -34,12 +34,13 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.BatchRefUpdateEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventDispatcher;
 import com.google.gerrit.server.events.RefUpdatedEvent;
@@ -47,6 +48,7 @@
 import com.google.inject.Provider;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig;
 import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.BatchApplyObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient;
@@ -55,14 +57,17 @@
 import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectsRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
+import java.net.URISyntaxException;
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import org.apache.http.client.ClientProtocolException;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
 import org.junit.Test;
@@ -79,6 +84,10 @@
   private static final String FOREIGN_INSTANCE_ID = "any other instance id";
   private static final String TEST_REF_NAME = "refs/meta/heads/anyref";
 
+  private static final Project.NameKey PROJECT = Project.nameKey("defaultProject");
+  private static final String NEW_OBJECT_ID =
+      ObjectId.fromString("3c1ddc050d7906adb0e29bc3bc46af8749b2f63b").getName();
+
   @Mock private WorkQueue wq;
   @Mock private Source source;
   @Mock private SourcesCollection sourceCollection;
@@ -87,7 +96,7 @@
   @Mock ReplicationStateListeners sl;
   @Mock FetchRestApiClient fetchRestApiClient;
   @Mock FetchApiClient.Factory fetchClientFactory;
-  @Mock AccountInfo accountInfo;
+  @Mock AccountAttribute accountAttribute;
   @Mock RevisionReader revReader;
   @Mock RevisionData revisionData;
   @Mock HttpResult successfulHttpResult;
@@ -95,7 +104,10 @@
   @Mock RevisionData revisionDataWithParents;
   List<ObjectId> revisionDataParentObjectIds;
   @Mock HttpResult httpResult;
+  @Mock HttpResult batchHttpResult;
   @Mock ApplyObjectsRefsFilter applyObjectsRefsFilter;
+
+  @Mock Config config;
   ApplyObjectMetrics applyObjectMetrics;
   FetchReplicationMetrics fetchMetrics;
   ReplicationQueueMetrics queueMetrics;
@@ -104,6 +116,7 @@
   @Captor ArgumentCaptor<String> stringCaptor;
   @Captor ArgumentCaptor<Project.NameKey> projectNameKeyCaptor;
   @Captor ArgumentCaptor<List<RevisionData>> revisionsDataCaptor;
+  @Captor ArgumentCaptor<List<BatchApplyObjectData>> batchRefsCaptor;
 
   private ExcludedRefsFilter refsFilter;
   private ReplicationQueue objectUnderTest;
@@ -124,6 +137,8 @@
     when(source.getApis()).thenReturn(apis);
     when(sourceCollection.getAll()).thenReturn(Lists.newArrayList(source));
     when(rd.get()).thenReturn(sourceCollection);
+    when(config.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false))
+        .thenReturn(true);
     lenient()
         .when(revReader.read(any(), any(), anyString(), eq(0)))
         .thenReturn(Optional.of(revisionData));
@@ -149,12 +164,17 @@
     lenient()
         .when(fetchRestApiClient.callSendObjects(any(), anyString(), anyLong(), any(), any()))
         .thenReturn(httpResult);
+    lenient()
+        .when(fetchRestApiClient.callBatchSendObject(any(), any(), anyLong(), any()))
+        .thenReturn(batchHttpResult);
     when(fetchRestApiClient.callFetch(any(), anyString(), any())).thenReturn(fetchHttpResult);
     when(fetchRestApiClient.initProject(any(), any())).thenReturn(successfulHttpResult);
     when(successfulHttpResult.isSuccessful()).thenReturn(true);
     when(httpResult.isSuccessful()).thenReturn(true);
+    when(batchHttpResult.isSuccessful()).thenReturn(true);
     when(fetchHttpResult.isSuccessful()).thenReturn(true);
     when(httpResult.isProjectMissing(any())).thenReturn(false);
+    when(batchHttpResult.isProjectMissing(any())).thenReturn(false);
     when(applyObjectsRefsFilter.match(any())).thenReturn(false);
 
     applyObjectMetrics = new ApplyObjectMetrics("pull-replication", new DisabledMetricMaker());
@@ -175,36 +195,64 @@
             fetchMetrics,
             queueMetrics,
             LOCAL_INSTANCE_ID,
+            config,
             applyObjectsRefsFilter,
             shutdownState);
   }
 
   @Test
-  public void shouldCallSendObjectWhenMetaRef() throws ClientProtocolException, IOException {
+  public void shouldCallBatchSendObjectWhenMetaRef() throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient).callBatchSendObject(any(), any(), anyLong(), any());
+  }
+
+  @Test
+  public void shouldCallSendObjectWhenMetaRefAndRefUpdateEvent() throws IOException {
+    when(config.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false))
+        .thenReturn(false);
+
+    objectUnderTest =
+        new ReplicationQueue(
+            wq,
+            rd,
+            dis,
+            sl,
+            fetchClientFactory,
+            refsFilter,
+            () -> revReader,
+            applyObjectMetrics,
+            fetchMetrics,
+            queueMetrics,
+            LOCAL_INSTANCE_ID,
+            config,
+            applyObjectsRefsFilter,
+            shutdownState);
+
     Event event = new TestEvent("refs/changes/01/1/meta");
     objectUnderTest.start();
     objectUnderTest.onEvent(event);
 
-    verify(fetchRestApiClient).callSendObjects(any(), anyString(), anyLong(), any(), any());
+    verify(fetchRestApiClient).callBatchSendObject(any(), any(), anyLong(), any());
   }
 
   @Test
-  public void shouldIgnoreEventWhenIsNotLocalInstanceId()
-      throws ClientProtocolException, IOException {
-    Event event = new TestEvent();
+  public void shouldIgnoreEventWhenIsNotLocalInstanceId() throws IOException {
+    Event event = generateBatchRefUpdateEvent(TEST_REF_NAME);
     event.instanceId = FOREIGN_INSTANCE_ID;
     objectUnderTest.start();
     objectUnderTest.onEvent(event);
 
-    verify(fetchRestApiClient, never())
-        .callSendObjects(any(), anyString(), anyLong(), any(), any());
+    verify(fetchRestApiClient, never()).callBatchSendObject(any(), any(), anyLong(), any());
   }
 
   @Test
   public void shouldCallInitProjectWhenProjectIsMissing() throws IOException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
-    when(httpResult.isSuccessful()).thenReturn(false);
-    when(httpResult.isProjectMissing(any())).thenReturn(true);
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isProjectMissing(any())).thenReturn(true);
     when(source.isCreateMissingRepositories()).thenReturn(true);
 
     objectUnderTest.start();
@@ -214,10 +262,28 @@
   }
 
   @Test
+  public void shouldCallSendObjectReorderingRefsHavingMetaAtTheEnd() throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta", "refs/changes/01/1/1");
+    objectUnderTest.start();
+    objectUnderTest.onEvent(event);
+    verifySendObjectOrdering("refs/changes/01/1/1", "refs/changes/01/1/meta");
+  }
+
+  @Test
+  public void shouldCallSendObjectKeepingMetaAtTheEnd() throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1", "refs/changes/01/1/meta");
+    objectUnderTest.start();
+    objectUnderTest.onEvent(event);
+    verifySendObjectOrdering("refs/changes/01/1/1", "refs/changes/01/1/meta");
+  }
+
+  @Test
   public void shouldNotCallInitProjectWhenReplicateNewRepositoriesNotSet() throws IOException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
-    when(httpResult.isSuccessful()).thenReturn(false);
-    when(httpResult.isProjectMissing(any())).thenReturn(true);
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isProjectMissing(any())).thenReturn(true);
+    lenient().when(httpResult.isSuccessful()).thenReturn(false);
+    lenient().when(httpResult.isProjectMissing(any())).thenReturn(true);
     when(source.isCreateMissingRepositories()).thenReturn(false);
 
     objectUnderTest.start();
@@ -227,18 +293,47 @@
   }
 
   @Test
-  public void shouldCallSendObjectWhenPatchSetRef() throws ClientProtocolException, IOException {
+  public void shouldCallBatchSendObjectWhenPatchSetRefAndRefUpdateEvent() throws IOException {
+    when(config.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false))
+        .thenReturn(false);
+
+    objectUnderTest =
+        new ReplicationQueue(
+            wq,
+            rd,
+            dis,
+            sl,
+            fetchClientFactory,
+            refsFilter,
+            () -> revReader,
+            applyObjectMetrics,
+            fetchMetrics,
+            queueMetrics,
+            LOCAL_INSTANCE_ID,
+            config,
+            applyObjectsRefsFilter,
+            shutdownState);
+
     Event event = new TestEvent("refs/changes/01/1/1");
     objectUnderTest.start();
     objectUnderTest.onEvent(event);
 
-    verify(fetchRestApiClient).callSendObjects(any(), anyString(), anyLong(), any(), any());
+    verify(fetchRestApiClient).callBatchSendObject(any(), any(), anyLong(), any());
+  }
+
+  @Test
+  public void shouldCallBatchSendObjectWhenPatchSetRef() throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1");
+    objectUnderTest.start();
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient).callBatchSendObject(any(), any(), anyLong(), any());
   }
 
   @Test
   public void shouldFallbackToCallFetchWhenIOException()
-      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
+      throws IOException, LargeObjectException, RefUpdateException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
     objectUnderTest.start();
 
     when(revReader.read(any(), any(), anyString(), anyInt())).thenThrow(IOException.class);
@@ -250,8 +345,8 @@
 
   @Test
   public void shouldFallbackToCallFetchWhenLargeRef()
-      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
-    Event event = new TestEvent("refs/changes/01/1/1");
+      throws IOException, LargeObjectException, RefUpdateException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1");
     objectUnderTest.start();
 
     when(revReader.read(any(), any(), anyString(), anyInt())).thenReturn(Optional.empty());
@@ -262,15 +357,49 @@
   }
 
   @Test
-  public void shouldFallbackToCallFetchWhenParentObjectIsMissing()
-      throws ClientProtocolException, IOException {
-    Event event = new TestEvent("refs/changes/01/1/1");
+  public void
+      shouldFallbackToCallFetchWhenParentObjectIsMissingAndRefDoesntMatchApplyObjectsRefsFilter()
+          throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1");
     objectUnderTest.start();
 
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient).callFetch(any(), anyString(), any());
+  }
+
+  @Test
+  public void
+      shouldFallbackToApplyObjectsForEachRefWhenParentObjectIsMissingAndRefMatchesApplyObjectsRefFilter()
+          throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1", "refs/changes/02/1/1");
+    objectUnderTest.start();
+
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(true);
+    when(applyObjectsRefsFilter.match(any())).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient, times(2))
+        .callSendObjects(any(), anyString(), anyLong(), any(), any());
+    verify(fetchRestApiClient, never()).callFetch(any(), anyString(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallFetchWhenSendBatchObjectNotAvailableAndApplyObjectFails()
+      throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1");
+    objectUnderTest.start();
+
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(false);
+    when(batchHttpResult.isSendBatchObjectAvailable()).thenReturn(false);
     when(httpResult.isSuccessful()).thenReturn(false);
-    when(httpResult.isParentObjectMissing()).thenReturn(true);
-    when(fetchRestApiClient.callSendObjects(any(), anyString(), anyLong(), any(), any()))
-        .thenReturn(httpResult);
+    when(httpResult.isParentObjectMissing()).thenReturn(false);
 
     objectUnderTest.onEvent(event);
 
@@ -279,14 +408,60 @@
 
   @Test
   public void shouldFallbackToApplyAllParentObjectsWhenParentObjectIsMissingOnMetaRef()
-      throws ClientProtocolException, IOException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
+      throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
     objectUnderTest.start();
 
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient, times(1))
+        .callSendObjects(any(), anyString(), anyLong(), revisionsDataCaptor.capture(), any());
+    List<List<RevisionData>> revisionsDataValues = revisionsDataCaptor.getAllValues();
+    assertThat(revisionsDataValues).hasSize(1);
+
+    List<RevisionData> firstRevisionsValues = revisionsDataValues.get(0);
+    assertThat(firstRevisionsValues).hasSize(1 + revisionDataParentObjectIds.size());
+    assertThat(firstRevisionsValues).contains(revisionData);
+  }
+
+  @Test
+  public void shouldFallbackToApplyAllParentObjectsWhenParentObjectIsMissingOnAllowedRefs()
+      throws IOException {
+    String refName = "refs/tags/test-tag";
+    Event event = generateBatchRefUpdateEvent(refName);
+    objectUnderTest.start();
+
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(true);
+    when(applyObjectsRefsFilter.match(refName)).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient, times(1))
+        .callSendObjects(any(), anyString(), anyLong(), revisionsDataCaptor.capture(), any());
+    List<List<RevisionData>> revisionsDataValues = revisionsDataCaptor.getAllValues();
+    assertThat(revisionsDataValues).hasSize(1);
+
+    List<RevisionData> firstRevisionsValues = revisionsDataValues.get(0);
+    assertThat(firstRevisionsValues).hasSize(1 + revisionDataParentObjectIds.size());
+    assertThat(firstRevisionsValues).contains(revisionData);
+  }
+
+  @Test
+  public void
+      shouldFallbackToApplyAllParentObjectsWhenSendBatchObjectNotAvailableAndParentObjectIsMissingOnMetaRef()
+          throws IOException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(false);
+    when(batchHttpResult.isSendBatchObjectAvailable()).thenReturn(false);
     when(httpResult.isSuccessful()).thenReturn(false, true);
     when(httpResult.isParentObjectMissing()).thenReturn(true, false);
-    when(fetchRestApiClient.callSendObjects(any(), anyString(), anyLong(), any(), any()))
-        .thenReturn(httpResult);
 
     objectUnderTest.onEvent(event);
 
@@ -304,16 +479,18 @@
   }
 
   @Test
-  public void shouldFallbackToApplyAllParentObjectsWhenParentObjectIsMissingOnAllowedRefs()
-      throws ClientProtocolException, IOException {
+  public void
+      shouldFallbackToApplyAllParentObjectsWhenSendBatchObjectNotAvailableAndParentObjectIsMissingOnAllowedRefs()
+          throws IOException {
     String refName = "refs/tags/test-tag";
-    Event event = new TestEvent(refName);
+    Event event = generateBatchRefUpdateEvent(refName);
     objectUnderTest.start();
 
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(false);
+    when(batchHttpResult.isSendBatchObjectAvailable()).thenReturn(false);
     when(httpResult.isSuccessful()).thenReturn(false, true);
     when(httpResult.isParentObjectMissing()).thenReturn(true, false);
-    when(fetchRestApiClient.callSendObjects(any(), anyString(), anyLong(), any(), any()))
-        .thenReturn(httpResult);
     when(applyObjectsRefsFilter.match(refName)).thenReturn(true);
 
     objectUnderTest.onEvent(event);
@@ -332,6 +509,27 @@
   }
 
   @Test
+  public void shouldCallBatchFetchForAllTheRefsInTheBatchIfApplyObjectFails()
+      throws IOException, URISyntaxException {
+    Event event = generateBatchRefUpdateEvent("refs/changes/01/1/1", "refs/changes/02/1/1");
+    when(batchHttpResult.isSuccessful()).thenReturn(false);
+    when(batchHttpResult.isParentObjectMissing()).thenReturn(true);
+    when(applyObjectsRefsFilter.match(any())).thenReturn(true, true);
+    when(httpResult.isSuccessful()).thenReturn(true, false);
+    when(httpResult.isParentObjectMissing()).thenReturn(true);
+
+    objectUnderTest.start();
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient, times(2))
+        .callSendObjects(any(), anyString(), anyLong(), any(), any());
+    verify(fetchRestApiClient)
+        .callFetch(PROJECT, "refs/changes/01/1/1", new URIish("http://localhost:18080"));
+    verify(fetchRestApiClient)
+        .callFetch(PROJECT, "refs/changes/02/1/1", new URIish("http://localhost:18080"));
+  }
+
+  @Test
   public void shouldSkipEventWhenMultiSiteVersionRef() throws IOException {
     FileBasedConfig fileConfig =
         new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED);
@@ -353,12 +551,13 @@
             fetchMetrics,
             queueMetrics,
             LOCAL_INSTANCE_ID,
+            config,
             applyObjectsRefsFilter,
             shutdownState);
-    Event event = new TestEvent("refs/multi-site/version");
+    Event event = generateBatchRefUpdateEvent("refs/multi-site/version");
     objectUnderTest.onEvent(event);
 
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountAttribute);
   }
 
   @Test
@@ -369,10 +568,10 @@
 
   @Test
   public void shouldSkipEventWhenStarredChangesRef() {
-    Event event = new TestEvent("refs/starred-changes/41/2941/1000000");
+    Event event = generateBatchRefUpdateEvent("refs/starred-changes/41/2941/1000000");
     objectUnderTest.onEvent(event);
 
-    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo);
+    verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountAttribute);
   }
 
   @Test
@@ -435,6 +634,36 @@
     return createTempDirectory(prefix);
   }
 
+  private BatchRefUpdateEvent generateBatchRefUpdateEvent(String... refs) {
+    List<RefUpdateAttribute> refUpdates =
+        Arrays.stream(refs)
+            .map(
+                ref -> {
+                  RefUpdateAttribute upd = new RefUpdateAttribute();
+                  upd.newRev = NEW_OBJECT_ID;
+                  upd.oldRev = ObjectId.zeroId().getName();
+                  upd.project = PROJECT.get();
+                  upd.refName = ref;
+                  return upd;
+                })
+            .collect(Collectors.toList());
+
+    BatchRefUpdateEvent event =
+        new BatchRefUpdateEvent(
+            PROJECT, Suppliers.ofInstance(refUpdates), Suppliers.ofInstance(accountAttribute));
+    event.instanceId = LOCAL_INSTANCE_ID;
+    return event;
+  }
+
+  private void verifySendObjectOrdering(String firstRef, String secondRef) throws IOException {
+    verify(fetchRestApiClient)
+        .callBatchSendObject(any(), batchRefsCaptor.capture(), anyLong(), any());
+    List<BatchApplyObjectData> batchRefs = batchRefsCaptor.getValue();
+
+    assertThat(batchRefs.get(0).refName()).isEqualTo(firstRef);
+    assertThat(batchRefs.get(1).refName()).isEqualTo(secondRef);
+  }
+
   private class TestEvent extends RefUpdatedEvent {
 
     public TestEvent() {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
index d0cd1b4..029db9e 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionTest.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ProjectResource;
@@ -216,6 +217,19 @@
     applyObjectAction.apply(projectResource, inputParams);
   }
 
+  @Test(expected = ResourceNotFoundException.class)
+  public void shouldRethrowResourceNotFoundException()
+      throws RestApiException, IOException, RefUpdateException, MissingParentObjectException {
+    RevisionInput inputParams =
+        new RevisionInput(label, refName, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+
+    doThrow(new ResourceNotFoundException("test_projects"))
+        .when(applyObjectCommand)
+        .applyObject(any(), anyString(), any(), anyString(), anyLong());
+
+    applyObjectAction.apply(projectResource, inputParams);
+  }
+
   private RevisionData createSampleRevisionData() {
     RevisionObjectData commitData =
         new RevisionObjectData(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
index 7f5a67c..681b695 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectCommandTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventDispatcher;
@@ -86,7 +87,9 @@
   private ApplyObjectCommand objectUnderTest;
 
   @Before
-  public void setup() throws MissingParentObjectException, IOException, URISyntaxException {
+  public void setup()
+      throws MissingParentObjectException, IOException, URISyntaxException,
+          ResourceNotFoundException {
     cache = CacheBuilder.newBuilder().build();
     RefUpdateState state = new RefUpdateState(TEST_REMOTE_NAME, RefUpdate.Result.NEW);
     TEST_REMOTE_URI = new URIish("git://some.remote.uri");
@@ -105,7 +108,7 @@
   @Test
   public void shouldSendEventWhenApplyObject()
       throws PermissionBackendException, IOException, RefUpdateException,
-          MissingParentObjectException {
+          MissingParentObjectException, ResourceNotFoundException {
     RevisionData sampleRevisionData =
         createSampleRevisionData(sampleCommitObjectId, sampleTreeObjectId);
     objectUnderTest.applyObject(
@@ -126,7 +129,8 @@
 
   @Test
   public void shouldInsertIntoApplyObjectsCacheWhenApplyObjectIsSuccessful()
-      throws IOException, RefUpdateException, MissingParentObjectException {
+      throws IOException, RefUpdateException, MissingParentObjectException,
+          ResourceNotFoundException {
     RevisionData sampleRevisionData =
         createSampleRevisionData(sampleCommitObjectId, sampleTreeObjectId);
     RevisionData sampleRevisionData2 =
@@ -156,7 +160,8 @@
 
   @Test(expected = RefUpdateException.class)
   public void shouldNotInsertIntoApplyObjectsCacheWhenApplyObjectIsFailure()
-      throws IOException, RefUpdateException, MissingParentObjectException {
+      throws IOException, RefUpdateException, MissingParentObjectException,
+          ResourceNotFoundException {
     RevisionData sampleRevisionData =
         createSampleRevisionData(sampleCommitObjectId, sampleTreeObjectId);
     RefUpdateState failureState = new RefUpdateState(TEST_REMOTE_NAME, RefUpdate.Result.IO_FAILURE);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectActionTest.java
new file mode 100644
index 0000000..d8dabf1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BatchApplyObjectActionTest.java
@@ -0,0 +1,214 @@
+// 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.pull.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionInput;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
+import java.util.Collections;
+import java.util.List;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Constants;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.stubbing.Answer;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BatchApplyObjectActionTest {
+
+  private static final long DUMMY_EVENT_TIMESTAMP = 1684875939;
+
+  private BatchApplyObjectAction batchApplyObjectAction;
+  private static final String LABEL = "instance-2-label";
+  private static final String REF_NAME = "refs/heads/master";
+  private static final String REF_META_NAME = "refs/meta/version";
+  private static final String SAMPLE_COMMIT_OBJECT_ID = "9f8d52853089a3cf00c02ff7bd0817bd4353a95a";
+  private static final String SAMPLE_TREE_OBJECT_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
+
+  private static final String SAMPLE_COMMIT_CONTENT =
+      "tree "
+          + SAMPLE_TREE_OBJECT_ID
+          + "\n"
+          + "parent 20eb48d28be86dc88fb4bef747f08de0fbefe12d\n"
+          + "author Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac> 1610471611 +0100\n"
+          + "committer Gerrit Code Review <root@maczech-XPS-15> 1610471611 +0100\n"
+          + "\n"
+          + "Update patch set 1\n"
+          + "\n"
+          + "Change has been successfully merged by Administrator\n"
+          + "\n"
+          + "Patch-set: 1\n"
+          + "Status: merged\n"
+          + "Tag: autogenerated:gerrit:merged\n"
+          + "Reviewer: Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac>\n"
+          + "Label: SUBM=+1\n"
+          + "Submission-id: 1904-1610471611558-783c0a2f\n"
+          + "Submitted-with: OK\n"
+          + "Submitted-with: OK: Code-Review: Gerrit User 1000000 <1000000@69ec38f0-350e-4d9c-96d4-bc956f2faaac>";
+
+  @Mock private ApplyObjectAction applyObjectAction;
+  @Mock private ProjectResource projectResource;
+
+  @Before
+  public void setup() {
+    batchApplyObjectAction = new BatchApplyObjectAction(applyObjectAction);
+  }
+
+  @Test
+  public void shouldDelegateToApplyObjectActionForEveryRevision() throws RestApiException {
+    RevisionInput first =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput second =
+        new RevisionInput(LABEL, "foo", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+
+    batchApplyObjectAction.apply(projectResource, List.of(first, second));
+
+    verify(applyObjectAction).apply(projectResource, first);
+    verify(applyObjectAction).apply(projectResource, second);
+  }
+
+  @Test
+  public void shouldReturnOkResponseCodeWhenAllRevisionsAreProcessedSuccessfully()
+      throws RestApiException {
+    RevisionInput first =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput second =
+        new RevisionInput(LABEL, "foo", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+
+    when(applyObjectAction.apply(projectResource, first))
+        .thenAnswer((Answer<Response<?>>) invocation -> Response.created(first));
+    when(applyObjectAction.apply(projectResource, second))
+        .thenAnswer((Answer<Response<?>>) invocation -> Response.created(second));
+
+    Response<?> response = batchApplyObjectAction.apply(projectResource, List.of(first, second));
+
+    assertThat(response.statusCode()).isEqualTo(SC_OK);
+  }
+
+  @Test
+  public void shouldReturnAListWithAllTheRevisionsInResponseBodyOnSuccess()
+      throws RestApiException {
+    RevisionInput first =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput second =
+        new RevisionInput(LABEL, "foo", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    Response<?> firstResponse = Response.created(first);
+    Response<?> secondResponse = Response.created(second);
+
+    when(applyObjectAction.apply(projectResource, first))
+        .thenAnswer((Answer<Response<?>>) invocation -> firstResponse);
+    when(applyObjectAction.apply(projectResource, second))
+        .thenAnswer((Answer<Response<?>>) invocation -> secondResponse);
+
+    Response<?> response = batchApplyObjectAction.apply(projectResource, List.of(first, second));
+
+    assertThat((List<Response<?>>) response.value())
+        .isEqualTo(List.of(firstResponse, secondResponse));
+  }
+
+  @Test
+  public void shouldAcceptAMixOfCreatesAndDeletes() throws RestApiException {
+    RevisionInput delete = new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, null);
+    RevisionInput create =
+        new RevisionInput(LABEL, "foo", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    Response<?> deleteResponse = Response.withStatusCode(HttpServletResponse.SC_NO_CONTENT, "");
+    Response<?> createResponse = Response.created(create);
+
+    when(applyObjectAction.apply(projectResource, delete))
+        .thenAnswer((Answer<Response<?>>) invocation -> deleteResponse);
+    when(applyObjectAction.apply(projectResource, create))
+        .thenAnswer((Answer<Response<?>>) invocation -> createResponse);
+
+    Response<?> response = batchApplyObjectAction.apply(projectResource, List.of(delete, create));
+
+    assertThat((List<Response<?>>) response.value())
+        .isEqualTo(List.of(deleteResponse, createResponse));
+  }
+
+  @Test
+  public void shouldReturnOneOkCodeEvenIfInputContainsBothCreatesAndDeletes()
+      throws RestApiException {
+    RevisionInput create =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput delete = new RevisionInput(LABEL, REF_META_NAME, DUMMY_EVENT_TIMESTAMP + 1, null);
+
+    List<RevisionInput> inputs = List.of(create, delete);
+
+    Response<?> response = batchApplyObjectAction.apply(projectResource, inputs);
+
+    assertThat(response.statusCode()).isEqualTo(SC_OK);
+  }
+
+  @Test(expected = RestApiException.class)
+  public void shouldThrowARestApiExceptionIfProcessingFailsForAnyOfTheRevisions()
+      throws RestApiException {
+    RevisionInput good =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput bad =
+        new RevisionInput(LABEL, "bad", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+
+    when(applyObjectAction.apply(projectResource, good))
+        .thenAnswer((Answer<Response<?>>) invocation -> Response.created(good));
+    when(applyObjectAction.apply(projectResource, bad))
+        .thenThrow(new MergeConflictException("BOOM"));
+
+    batchApplyObjectAction.apply(projectResource, List.of(good, bad));
+  }
+
+  @Test
+  public void shouldStopProcessingWhenAFailureOccurs() throws RestApiException {
+    RevisionInput good =
+        new RevisionInput(LABEL, REF_NAME, DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+    RevisionInput bad =
+        new RevisionInput(LABEL, "bad", DUMMY_EVENT_TIMESTAMP, createSampleRevisionData());
+
+    when(applyObjectAction.apply(projectResource, bad))
+        .thenThrow(new MergeConflictException("BOOM"));
+
+    try {
+      batchApplyObjectAction.apply(projectResource, List.of(bad, good));
+    } catch (MergeConflictException e) {
+      verify(applyObjectAction, never()).apply(projectResource, good);
+    }
+  }
+
+  private RevisionData createSampleRevisionData() {
+    RevisionObjectData commitData =
+        new RevisionObjectData(
+            SAMPLE_COMMIT_OBJECT_ID, Constants.OBJ_COMMIT, SAMPLE_COMMIT_CONTENT.getBytes());
+    RevisionObjectData treeData =
+        new RevisionObjectData(SAMPLE_TREE_OBJECT_ID, Constants.OBJ_TREE, new byte[] {});
+    return createSampleRevisionData(commitData, treeData);
+  }
+
+  private RevisionData createSampleRevisionData(
+      RevisionObjectData commitData, RevisionObjectData treeData) {
+    return new RevisionData(Collections.emptyList(), commitData, treeData, Lists.newArrayList());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java
index ca69f06..9b0106c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/BearerAuthenticationFilterTest.java
@@ -91,6 +91,11 @@
   }
 
   @Test
+  public void shouldAuthenticateWhenBatchApplyObject() throws ServletException, IOException {
+    authenticateAndFilter("any-prefix/pull-replication~batch-apply-object", NO_QUERY_PARAMETERS);
+  }
+
+  @Test
   public void shouldAuthenticateWhenDeleteProject() throws ServletException, IOException {
     authenticateAndFilter("any-prefix/pull-replication~delete-project", NO_QUERY_PARAMETERS);
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
index 7c7846c..3bd9d35 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -181,6 +182,50 @@
             assertHttpResponseCode(HttpServletResponse.SC_OK));
   }
 
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldRemoveFromTheProjectCacheWhenProjectIsSuccessfullyDeleted() throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithAuthenticationPrefix(testProjectName);
+    assertThat(projectCache.get(project).isPresent()).isTrue();
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
+    assertThat(projectCache.get(project).isPresent()).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldRemoveFromtheReplicaProjectCacheWhenProjectIsSuccessfullyDeletedFromTheReplica()
+      throws Exception {
+    String testProjectName = project.get();
+    url = getURLWithAuthenticationPrefix(testProjectName);
+    assertThat(projectCache.get(project).isPresent()).isTrue();
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            withBasicAuthenticationAsAdmin(createDeleteRequest()),
+            assertHttpResponseCode(HttpServletResponse.SC_OK));
+    assertThat(projectCache.get(project).isPresent()).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+  public void shouldNotRemoveFromTheReplicaCacheIfAProjectCannotBeDeleted() throws Exception {
+    assertThat(projectCache.get(project).isPresent()).isTrue();
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED));
+    assertThat(projectCache.get(project).isPresent()).isTrue();
+  }
+
   @Override
   protected String getURLWithAuthenticationPrefix(String projectName) {
     return String.format(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
index 5ede668..850d04a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilterTest.java
@@ -9,7 +9,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.internal.verification.VerificationModeFactory.atLeastOnce;
-import static org.mockito.internal.verification.VerificationModeFactory.times;
 
 import com.google.common.net.MediaType;
 import com.google.gerrit.entities.Project;
@@ -42,6 +41,7 @@
   @Mock private FetchAction fetchAction;
   @Mock private ApplyObjectAction applyObjectAction;
   @Mock private ApplyObjectsAction applyObjectsAction;
+  @Mock private BatchApplyObjectAction batchApplyObjectAction;
   @Mock private ProjectInitializationAction projectInitializationAction;
   @Mock private UpdateHeadAction updateHEADAction;
   @Mock private ProjectDeletionAction projectDeletionAction;
@@ -62,6 +62,9 @@
       String.format("any-prefix/projects/%s/%s~apply-objects", PROJECT_NAME, PLUGIN_NAME);
   private final String HEAD_URI =
       String.format("any-prefix/projects/%s/%s~HEAD", PROJECT_NAME, PLUGIN_NAME);
+
+  private final String BATCH_APPLY_OBJECT_URI =
+      String.format("any-prefix/projects/%s/%s~batch-apply-object", PROJECT_NAME, PLUGIN_NAME);
   private final String DELETE_PROJECT_URI =
       String.format("any-prefix/projects/%s/%s~delete-project", PROJECT_NAME, PLUGIN_NAME);
   private final String INIT_PROJECT_URI =
@@ -78,6 +81,7 @@
         fetchAction,
         applyObjectAction,
         applyObjectsAction,
+        batchApplyObjectAction,
         projectInitializationAction,
         updateHEADAction,
         projectDeletionAction,
@@ -180,7 +184,6 @@
     final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
     pullReplicationFilter.doFilter(request, response, filterChain);
 
-    verify(request, times(5)).getRequestURI();
     verify(projectInitializationAction).initProject(eq(PROJECT_NAME_GIT));
     verify(response).getWriter();
   }
@@ -211,7 +214,6 @@
     final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
     pullReplicationFilter.doFilter(request, response, filterChain);
 
-    verify(request, times(7)).getRequestURI();
     verify(projectCache).get(Project.nameKey(PROJECT_NAME));
     verify(projectDeletionAction).apply(any(ProjectResource.class), any());
     verify(response).getWriter();
@@ -267,7 +269,7 @@
   }
 
   @Test
-  public void shouldBe500WhenResourceNotFound() throws Exception {
+  public void shouldBe404WhenResourceNotFound() throws Exception {
     when(request.getRequestURI()).thenReturn(DELETE_PROJECT_URI);
     when(request.getMethod()).thenReturn("DELETE");
     when(projectCache.get(Project.nameKey(PROJECT_NAME))).thenReturn(Optional.of(projectState));
@@ -278,7 +280,7 @@
     final PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
     pullReplicationFilter.doFilter(request, response, filterChain);
 
-    verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND);
   }
 
   @Test
@@ -364,4 +366,33 @@
 
     verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
   }
+
+  @Test
+  public void shouldFilterBatchApplyObjectAction() throws Exception {
+
+    byte[] payloadApplyObject =
+        ("[{\"label\":\"Replication\",\"ref_name\":\"refs/heads/foo\","
+                + "\"revision_data\":{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}"
+                + "},"
+                + "{\"label\":\"Replication\",\"ref_name\":\"refs/heads/bar\","
+                + "\"revision_data\":{"
+                + "\"commit_object\":{\"type\":1,\"content\":\"some-content\"},"
+                + "\"tree_object\":{\"type\":2,\"content\":\"some-content\"},"
+                + "\"blobs\":[]}"
+                + "}]")
+            .getBytes(StandardCharsets.UTF_8);
+
+    defineBehaviours(payloadApplyObject, BATCH_APPLY_OBJECT_URI);
+
+    when(batchApplyObjectAction.apply(any(), any())).thenReturn(OK_RESPONSE);
+
+    PullReplicationFilter pullReplicationFilter = createPullReplicationFilter();
+    pullReplicationFilter.doFilter(request, response, filterChain);
+
+    verifyBehaviours();
+    verify(batchApplyObjectAction).apply(any(ProjectResource.class), any());
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectDataTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectDataTest.java
new file mode 100644
index 0000000..bf74b56
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/data/BatchApplyObjectDataTest.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication.pull.api.data;
+
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BatchApplyObjectDataTest {
+
+  @Mock private RevisionData revisionData;
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldFailIfRevisionDataIsPresentForADelete() {
+    BatchApplyObjectData.create("foo", Optional.of(revisionData), true);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
index cdb238e..e64bc6f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientBase.java
@@ -30,6 +30,7 @@
 import com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig;
 import com.googlesource.gerrit.plugins.replication.pull.BearerTokenProvider;
 import com.googlesource.gerrit.plugins.replication.pull.Source;
+import com.googlesource.gerrit.plugins.replication.pull.api.data.BatchApplyObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter;
@@ -37,9 +38,11 @@
 import java.io.InputStreamReader;
 import java.net.URISyntaxException;
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
 import org.apache.http.Header;
-import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
@@ -150,8 +153,7 @@
   protected abstract void assertAuthentication(HttpRequestBase httpRequest);
 
   @Test
-  public void shouldCallFetchEndpoint()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallFetchEndpoint() throws IOException, URISyntaxException {
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
@@ -167,8 +169,7 @@
   }
 
   @Test
-  public void shouldByDefaultCallSyncFetchForAllRefs()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldByDefaultCallSyncFetchForAllRefs() throws IOException, URISyntaxException {
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
@@ -179,8 +180,7 @@
   }
 
   @Test
-  public void shouldCallAsyncFetchForAllRefs()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallAsyncFetchForAllRefs() throws IOException, URISyntaxException {
 
     when(config.getStringList("replication", null, "syncRefs"))
         .thenReturn(new String[] {"NO_SYNC_REFS"});
@@ -205,8 +205,7 @@
   }
 
   @Test
-  public void shouldCallSyncFetchOnlyForMetaRef()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallSyncFetchOnlyForMetaRef() throws IOException, URISyntaxException {
     String metaRefName = "refs/changes/01/101/meta";
     String expectedMetaRefPayload =
         "{\"label\":\"Replication\", \"ref_name\": \"" + metaRefName + "\", \"async\":false}";
@@ -237,8 +236,7 @@
   }
 
   @Test
-  public void shouldCallFetchEndpointWithPayload()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallFetchEndpointWithPayload() throws IOException, URISyntaxException {
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
@@ -249,8 +247,7 @@
   }
 
   @Test
-  public void shouldSetContentTypeHeader()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldSetContentTypeHeader() throws IOException, URISyntaxException {
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
@@ -262,8 +259,7 @@
   }
 
   @Test
-  public void shouldCallSendObjectEndpoint()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallSendObjectEndpoint() throws IOException, URISyntaxException {
 
     objectUnderTest.callSendObject(
         Project.nameKey("test_repo"),
@@ -285,8 +281,7 @@
   }
 
   @Test
-  public void shouldCallSendObjectEndpointWithPayload()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldCallSendObjectEndpointWithPayload() throws IOException, URISyntaxException {
 
     objectUnderTest.callSendObject(
         Project.nameKey("test_repo"),
@@ -303,8 +298,7 @@
   }
 
   @Test
-  public void shouldSetContentTypeHeaderForSendObjectCall()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldSetContentTypeHeaderForSendObjectCall() throws IOException, URISyntaxException {
 
     objectUnderTest.callFetch(Project.nameKey("test_repo"), refName, new URIish(api));
 
@@ -364,8 +358,7 @@
   }
 
   @Test
-  public void shouldUseReplicationLabelWhenProvided()
-      throws ClientProtocolException, IOException, URISyntaxException {
+  public void shouldUseReplicationLabelWhenProvided() throws IOException, URISyntaxException {
     when(config.getString("replication", null, "instanceLabel")).thenReturn(instanceId);
     FetchRestApiClient objectUnderTest =
         new FetchRestApiClient(
@@ -441,19 +434,141 @@
     assertAuthentication(httpPut);
   }
 
+  @Test
+  public void shouldCallBatchSendObjectEndpoint() throws IOException, URISyntaxException {
+
+    List<BatchApplyObjectData> batchApplyObjects = new ArrayList<>();
+    batchApplyObjects.add(
+        BatchApplyObjectData.create(refName, Optional.of(createSampleRevisionData("a")), false));
+
+    objectUnderTest.callBatchSendObject(
+        Project.nameKey("test_repo"), batchApplyObjects, eventCreatedOn, new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
+
+    HttpPost httpPost = httpPostCaptor.getValue();
+    assertThat(httpPost.getURI().getHost()).isEqualTo("gerrit-host");
+    assertThat(httpPost.getURI().getPath())
+        .isEqualTo(
+            String.format(
+                "%s/projects/test_repo/pull-replication~batch-apply-object",
+                urlAuthenticationPrefix()));
+    assertAuthentication(httpPost);
+  }
+
+  @Test
+  public void shouldCallBatchApplyObjectEndpointWithAListOfRefsInPayload()
+      throws IOException, URISyntaxException {
+    List<BatchApplyObjectData> batchApplyObjects = new ArrayList<>();
+    RevisionData revisionA = createSampleRevisionData("a");
+    RevisionData revisionB = createSampleRevisionData("b");
+    String refNameB = "refs/heads/b";
+    batchApplyObjects.add(BatchApplyObjectData.create(refName, Optional.of(revisionA), false));
+    batchApplyObjects.add(BatchApplyObjectData.create(refNameB, Optional.of(revisionB), false));
+
+    objectUnderTest.callBatchSendObject(
+        Project.nameKey("test_repo"), batchApplyObjects, eventCreatedOn, new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
+
+    HttpPost httpPost = httpPostCaptor.getValue();
+
+    String expectedSendObjectsPayload =
+        "[{\"label\":\"Replication\",\"ref_name\":\""
+            + refName
+            + "\",\"event_created_on\":"
+            + eventCreatedOn
+            + ",\"revision_data\":{\"commit_object\":{\"sha1\":\""
+            + revisionA.getCommitObject().getSha1()
+            + "\",\"type\":1,\"content\":\"Y29tbWl0YWNvbnRlbnQ\\u003d\"},\"tree_object\":{\"sha1\":\""
+            + revisionA.getTreeObject().getSha1()
+            + "\",\"type\":2,\"content\":\"dHJlZWFjb250ZW50\"},\"blobs\":[{\"sha1\":\""
+            + revisionA.getBlobs().get(0).getSha1()
+            + "\",\"type\":3,\"content\":\"YmxvYmFjb250ZW50\"}]}},"
+            + "{\"label\":\"Replication\",\"ref_name\":\""
+            + refNameB
+            + "\",\"event_created_on\":"
+            + eventCreatedOn
+            + ",\"revision_data\":{\"commit_object\":{\"sha1\":\""
+            + revisionB.getCommitObject().getSha1()
+            + "\",\"type\":1,\"content\":\"Y29tbWl0YmNvbnRlbnQ\\u003d\"},\"tree_object\":{\"sha1\":\""
+            + revisionB.getTreeObject().getSha1()
+            + "\",\"type\":2,\"content\":\"dHJlZWJjb250ZW50\"},\"blobs\":[{\"sha1\":\""
+            + revisionB.getBlobs().get(0).getSha1()
+            + "\",\"type\":3,\"content\":\"YmxvYmJjb250ZW50\"}]}}]";
+    assertThat(readPayload(httpPost)).isEqualTo(expectedSendObjectsPayload);
+  }
+
+  @Test
+  public void shouldCallBatchApplyObjectEndpointWithNoRevisionDataForDeletes()
+      throws IOException, URISyntaxException {
+    List<BatchApplyObjectData> batchApplyObjects = new ArrayList<>();
+    batchApplyObjects.add(BatchApplyObjectData.create(refName, Optional.empty(), true));
+
+    objectUnderTest.callBatchSendObject(
+        Project.nameKey("test_repo"), batchApplyObjects, eventCreatedOn, new URIish(api));
+
+    verify(httpClient, times(1)).execute(httpPostCaptor.capture(), any());
+
+    HttpPost httpPost = httpPostCaptor.getValue();
+
+    String expectedSendObjectsPayload =
+        "[{\"label\":\"Replication\",\"ref_name\":\""
+            + refName
+            + "\",\"event_created_on\":"
+            + eventCreatedOn
+            + "}]";
+    assertThat(readPayload(httpPost)).isEqualTo(expectedSendObjectsPayload);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void shouldThrowExceptionIfDeleteFlagIsSetButRevisionDataIsPresentForBatchSendEndpoint()
+      throws IOException, URISyntaxException {
+    List<BatchApplyObjectData> batchApplyObjects = new ArrayList<>();
+    batchApplyObjects.add(
+        BatchApplyObjectData.create(refName, Optional.of(createSampleRevisionData()), true));
+
+    objectUnderTest.callBatchSendObject(
+        Project.nameKey("test_repo"), batchApplyObjects, eventCreatedOn, new URIish(api));
+  }
+
   public String readPayload(HttpPost entity) throws UnsupportedOperationException, IOException {
     ByteBuffer buf = IO.readWholeStream(entity.getEntity().getContent(), 1024);
     return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();
   }
 
-  private RevisionData createSampleRevisionData() {
+  private RevisionData createSampleRevisionData(String prefix) {
+    String commitPrefix = "commit" + prefix;
+    String treePrefix = "tree" + prefix;
+    String blobPrefix = "blob" + prefix;
+    return createSampleRevisionData(
+        commitPrefix,
+        commitPrefix + "content",
+        treePrefix,
+        treePrefix + "content",
+        blobPrefix,
+        blobPrefix + "content");
+  }
+
+  private RevisionData createSampleRevisionData(
+      String commitObjectId,
+      String commitContent,
+      String treeObjectId,
+      String treeContent,
+      String blobObjectId,
+      String blobContent) {
     RevisionObjectData commitData =
-        new RevisionObjectData(commitObjectId, Constants.OBJ_COMMIT, commitObject.getBytes());
+        new RevisionObjectData(commitObjectId, Constants.OBJ_COMMIT, commitContent.getBytes());
     RevisionObjectData treeData =
-        new RevisionObjectData(treeObjectId, Constants.OBJ_TREE, treeObject.getBytes());
+        new RevisionObjectData(treeObjectId, Constants.OBJ_TREE, treeContent.getBytes());
     RevisionObjectData blobData =
-        new RevisionObjectData(blobObjectId, Constants.OBJ_BLOB, blobObject.getBytes());
+        new RevisionObjectData(blobObjectId, Constants.OBJ_BLOB, blobContent.getBytes());
     return new RevisionData(
         Collections.emptyList(), commitData, treeData, Lists.newArrayList(blobData));
   }
+
+  private RevisionData createSampleRevisionData() {
+    return createSampleRevisionData(
+        commitObjectId, commitObject, treeObjectId, treeObject, blobObjectId, blobObject);
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java
index 644afce..2b03d3f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBasicAuthenticationTest.java
@@ -23,7 +23,6 @@
 import java.util.Optional;
 import org.apache.http.Header;
 import org.apache.http.HttpHeaders;
-import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpRequestBase;
 import org.eclipse.jgit.transport.CredentialItem;
 import org.junit.Before;
@@ -36,7 +35,7 @@
 public class FetchRestApiClientWithBasicAuthenticationTest extends FetchRestApiClientBase {
 
   @Before
-  public void setup() throws ClientProtocolException, IOException {
+  public void setup() throws IOException {
     when(bearerTokenProvider.get()).thenReturn(Optional.empty());
     when(credentialProvider.supports(any()))
         .thenAnswer(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java
index 90d71ad..a79f4b8 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientWithBearerTokenTest.java
@@ -23,7 +23,6 @@
 import java.util.Optional;
 import org.apache.http.Header;
 import org.apache.http.HttpHeaders;
-import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpRequestBase;
 import org.junit.Before;
 import org.junit.runner.RunWith;
@@ -33,7 +32,7 @@
 public class FetchRestApiClientWithBearerTokenTest extends FetchRestApiClientBase {
 
   @Before
-  public void setup() throws ClientProtocolException, IOException {
+  public void setup() throws IOException {
     when(bearerTokenProvider.get()).thenReturn(Optional.of("some-bearer-token"));
     when(replicationConfig.getConfig()).thenReturn(config);
     when(config.getStringList("replication", null, "syncRefs")).thenReturn(new String[0]);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java
index d82f3a5..900723e 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/HttpResultTest.java
@@ -30,21 +30,25 @@
   public static Iterable<Object[]> data() {
     return Arrays.asList(
         new Object[][] {
-          {HttpServletResponse.SC_OK, true},
-          {HttpServletResponse.SC_CREATED, true},
-          {HttpServletResponse.SC_ACCEPTED, true},
-          {HttpServletResponse.SC_NO_CONTENT, true},
-          {HttpServletResponse.SC_BAD_REQUEST, false},
-          {HttpServletResponse.SC_CONFLICT, false}
+          {HttpServletResponse.SC_OK, true, true},
+          {HttpServletResponse.SC_CREATED, true, true},
+          {HttpServletResponse.SC_ACCEPTED, true, true},
+          {HttpServletResponse.SC_NO_CONTENT, true, true},
+          {HttpServletResponse.SC_BAD_REQUEST, false, true},
+          {HttpServletResponse.SC_NOT_FOUND, false, false},
+          {HttpServletResponse.SC_CONFLICT, false, true}
         });
   }
 
   private Integer httpStatus;
   private boolean isSuccessful;
+  private boolean isSendBatchObjectAvailable;
 
-  public HttpResultTest(Integer httpStatus, Boolean isSuccessful) {
+  public HttpResultTest(
+      Integer httpStatus, Boolean isSuccessful, Boolean isSendBatchObjectAvailable) {
     this.httpStatus = httpStatus;
     this.isSuccessful = isSuccessful;
+    this.isSendBatchObjectAvailable = isSendBatchObjectAvailable;
   }
 
   @Test
@@ -52,4 +56,10 @@
     HttpResult httpResult = new HttpResult(httpStatus, Optional.empty());
     assertThat(httpResult.isSuccessful()).isEqualTo(isSuccessful);
   }
+
+  @Test
+  public void httpResultIsSendBatchObjectAvailable() {
+    HttpResult httpResult = new HttpResult(httpStatus, Optional.empty());
+    assertThat(httpResult.isSendBatchObjectAvailable()).isEqualTo(isSendBatchObjectAvailable);
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java
deleted file mode 100644
index 81a4fc0..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/FetchRefReplicatedEventHandlerTest.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.plugins.replication.pull.event;
-
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.googlesource.gerrit.plugins.replication.pull.Context;
-import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
-import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.URIish;
-import org.junit.Before;
-import org.junit.Test;
-
-public class FetchRefReplicatedEventHandlerTest {
-  private ChangeIndexer changeIndexerMock;
-  private FetchRefReplicatedEventHandler fetchRefReplicatedEventHandler;
-  private static URIish sourceUri;
-
-  @Before
-  public void setUp() throws Exception {
-    changeIndexerMock = mock(ChangeIndexer.class);
-    fetchRefReplicatedEventHandler = new FetchRefReplicatedEventHandler(changeIndexerMock);
-    sourceUri = new URIish("git://aSourceNode/testProject.git");
-  }
-
-  @Test
-  public void onEventShouldIndexExistingChange() {
-    Project.NameKey projectNameKey = Project.nameKey("testProject");
-    String ref = "refs/changes/41/41/meta";
-    Change.Id changeId = Change.Id.fromRef(ref);
-    try {
-      Context.setLocalEvent(true);
-      fetchRefReplicatedEventHandler.onEvent(
-          new FetchRefReplicatedEvent(
-              projectNameKey.get(),
-              ref,
-              sourceUri,
-              ReplicationState.RefFetchResult.SUCCEEDED,
-              RefUpdate.Result.FAST_FORWARD));
-      verify(changeIndexerMock, times(1)).index(eq(projectNameKey), eq(changeId));
-    } finally {
-      Context.unsetLocalEvent();
-    }
-  }
-
-  @Test
-  public void onEventShouldNotIndexIfNotLocalEvent() {
-    Project.NameKey projectNameKey = Project.nameKey("testProject");
-    String ref = "refs/changes/41/41/meta";
-    Change.Id changeId = Change.Id.fromRef(ref);
-    fetchRefReplicatedEventHandler.onEvent(
-        new FetchRefReplicatedEvent(
-            projectNameKey.get(),
-            ref,
-            sourceUri,
-            ReplicationState.RefFetchResult.SUCCEEDED,
-            RefUpdate.Result.FAST_FORWARD));
-    verify(changeIndexerMock, never()).index(eq(projectNameKey), eq(changeId));
-  }
-
-  @Test
-  public void onEventShouldIndexOnlyMetaRef() {
-    Project.NameKey projectNameKey = Project.nameKey("testProject");
-    String ref = "refs/changes/41/41/1";
-    Change.Id changeId = Change.Id.fromRef(ref);
-    fetchRefReplicatedEventHandler.onEvent(
-        new FetchRefReplicatedEvent(
-            projectNameKey.get(),
-            ref,
-            sourceUri,
-            ReplicationState.RefFetchResult.SUCCEEDED,
-            RefUpdate.Result.FAST_FORWARD));
-    verify(changeIndexerMock, never()).index(eq(projectNameKey), eq(changeId));
-  }
-
-  @Test
-  public void onEventShouldNotIndexMissingChange() {
-    fetchRefReplicatedEventHandler.onEvent(
-        new FetchRefReplicatedEvent(
-            Project.nameKey("testProject").get(),
-            "invalidRef",
-            sourceUri,
-            ReplicationState.RefFetchResult.SUCCEEDED,
-            RefUpdate.Result.FAST_FORWARD));
-    verify(changeIndexerMock, never()).index(any(), any());
-  }
-
-  @Test
-  public void onEventShouldNotIndexFailingChange() {
-    Project.NameKey projectNameKey = Project.nameKey("testProject");
-    String ref = "refs/changes/41/41/meta";
-    fetchRefReplicatedEventHandler.onEvent(
-        new FetchRefReplicatedEvent(
-            projectNameKey.get(),
-            ref,
-            sourceUri,
-            ReplicationState.RefFetchResult.FAILED,
-            RefUpdate.Result.FAST_FORWARD));
-    verify(changeIndexerMock, never()).index(any(), any());
-  }
-
-  @Test
-  public void onEventShouldNotIndexNotAttemptedChange() {
-    Project.NameKey projectNameKey = Project.nameKey("testProject");
-    String ref = "refs/changes/41/41/meta";
-    fetchRefReplicatedEventHandler.onEvent(
-        new FetchRefReplicatedEvent(
-            projectNameKey.get(),
-            ref,
-            sourceUri,
-            ReplicationState.RefFetchResult.NOT_ATTEMPTED,
-            RefUpdate.Result.FAST_FORWARD));
-    verify(changeIndexerMock, never()).index(any(), any());
-  }
-}