Prevent the synchronous replication of create-refs via apply-object

Allow to prevent the create-refs replication events from being
replicated via apply-object synchronously.

This helps fixing the inconsistencies created by out-of-order execution
of multiple create/delete of the same ref multiple times. Replicating
multiple non-ff updates such as create/delete refs may lead to
inconsistencies due to the unpredictable order of their execution on the
remote endpoints.

By default all the create-refs are still being replicated via
apply-object, however, should some refs being created and deleted
multiple times, it is possible to disable their synchronous replication
using the following setting on the replication.config:

[replication]
  applyObjectBannedCreateRefs = refs/heads/myvolatile/*

The above configuration will disable the synchronous replication of all
create-refs under refs/heads/myvolatile/*.

Bug: Issue 397737226
Change-Id: I60577748ca127264dde937a9d242aa0338ad758d
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 453cbf7..b94189e 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
@@ -48,6 +48,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult;
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpResultUtils;
+import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectBannedCreateRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectsRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
@@ -107,6 +108,7 @@
   private final String instanceId;
   private final boolean useBatchUpdateEvents;
   private ApplyObjectsRefsFilter applyObjectsRefsFilter;
+  private final ApplyObjectBannedCreateRefsFilter applyObjectsBannedCreateRefsFilter;
 
   @Inject
   ReplicationQueue(
@@ -122,6 +124,7 @@
       @GerritInstanceId String instanceId,
       @GerritServerConfig Config gerritConfig,
       ApplyObjectsRefsFilter applyObjectsRefsFilter,
+      ApplyObjectBannedCreateRefsFilter applyObjectsBannedCreateRefsFilter,
       ShutdownState shutdownState) {
     workQueue = wq;
     dispatcher = dis;
@@ -138,6 +141,7 @@
     this.useBatchUpdateEvents =
         gerritConfig.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false);
     this.applyObjectsRefsFilter = applyObjectsRefsFilter;
+    this.applyObjectsBannedCreateRefsFilter = applyObjectsBannedCreateRefsFilter;
   }
 
   @Override
@@ -367,8 +371,9 @@
               .map(ref -> toBatchApplyObject(project, ref, state))
               .collect(Collectors.toList());
 
-      if (!containsLargeOrDeletedRefs(refsBatch)) {
-        return ((source) -> callBatchSendObject(source, project, refsBatch, eventCreatedOn, state));
+      if (!containsLargeOrDeletedRefs(refsBatch)
+          && !hasCreateRefsBannedFromApplyObject(refsBatch)) {
+        return (source -> callBatchSendObject(source, project, refsBatch, eventCreatedOn, state));
       }
     } catch (UncheckedIOException e) {
       stateLog.error("Falling back to calling fetch", e, state);
@@ -398,6 +403,13 @@
     return batchApplyObjectData.stream().anyMatch(e -> e.revisionData().isEmpty());
   }
 
+  private boolean hasCreateRefsBannedFromApplyObject(
+      List<BatchApplyObjectData> batchApplyObjectData) {
+    return batchApplyObjectData.stream()
+        .filter(BatchApplyObjectData::isCreate)
+        .anyMatch(e -> applyObjectsBannedCreateRefsFilter.match(e.refName()));
+  }
+
   private Optional<HttpResult> callSendObject(
       FetchApiClient fetchClient,
       String remoteName,
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ApplyObjectBannedCreateRefsFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ApplyObjectBannedCreateRefsFilter.java
new file mode 100644
index 0000000..d7c685a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/filter/ApplyObjectBannedCreateRefsFilter.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2025 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.filter;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class ApplyObjectBannedCreateRefsFilter extends RefsFilter {
+  @Inject
+  public ApplyObjectBannedCreateRefsFilter(ReplicationConfig replicationConfig) {
+    super(replicationConfig);
+  }
+
+  @Override
+  protected List<String> getRefNamePatterns(Config cfg) {
+    String[] applyObjectBannedCreateRefs =
+        cfg.getStringList("replication", null, "applyObjectBannedCreateRefs");
+    return ImmutableList.copyOf(applyObjectBannedCreateRefs);
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 546df2e..da350ad 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -132,6 +132,33 @@
 :	If true, the default fetch refspec will be set to use forced update to
 	the local repository when no refspec is given.  By default, false.
 
+
+replication.applyObjectBannedCreateRefs
+:	Specify which refs created should be banned from being
+	replicated synchronously via apply-object.
+
+	It can be provided more than once, and supports three formats: regular expressions,
+	wildcard matching, and single ref matching. All three formats match are case-sensitive.
+
+	Values starting with a caret `^` are treated as regular
+	expressions. For the regular expressions details please follow
+	official [java documentation](https://docs.oracle.com/javase/tutorial/essential/regex/).
+
+	Please note that regular expressions could also be used
+	with inverse match.
+
+	Values that are not regular expressions and end in `*` are
+	treated as wildcard matches. Wildcards match refs whose
+	name agrees from the beginning until the trailing `*`. So
+	`foo/b*` would match the refs `foo/b`, `foo/bar`, and
+	`foo/baz`, but neither `foobar`, nor `bar/foo/baz`.
+
+	Values that are neither regular expressions nor wildcards are
+	treated as single ref matches. So `foo/bar` matches only
+	the ref `foo/bar`, but no other refs.
+
+	By default, set to empty (all create refs are replicated synchronously).
+
 replication.lockErrorMaxRetries
 :	Number of times to retry a replication operation if a lock
 	error is detected.
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 1c8a8ea..7f4ca4e 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
@@ -57,6 +57,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient;
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult;
+import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectBannedCreateRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ApplyObjectsRefsFilter;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
@@ -88,6 +89,8 @@
   private static final String TEST_REF_NAME = "refs/meta/heads/anyref";
 
   private static final Project.NameKey PROJECT = Project.nameKey("defaultProject");
+  private static final String OLD_OBJECT_ID =
+      ObjectId.fromString("00f11fd1e3206333235603f889837bad2692da4b").getName();
   private static final String NEW_OBJECT_ID =
       ObjectId.fromString("3c1ddc050d7906adb0e29bc3bc46af8749b2f63b").getName();
 
@@ -110,6 +113,7 @@
   @Mock HttpResult httpResult;
   @Mock HttpResult batchHttpResult;
   @Mock ApplyObjectsRefsFilter applyObjectsRefsFilter;
+  @Mock ApplyObjectBannedCreateRefsFilter applyObjectsBannedCreateRefsFilter;
 
   @Mock Config config;
   ApplyObjectMetrics applyObjectMetrics;
@@ -207,6 +211,7 @@
             LOCAL_INSTANCE_ID,
             config,
             applyObjectsRefsFilter,
+            applyObjectsBannedCreateRefsFilter,
             shutdownState);
   }
 
@@ -238,6 +243,7 @@
             LOCAL_INSTANCE_ID,
             config,
             applyObjectsRefsFilter,
+            applyObjectsBannedCreateRefsFilter,
             shutdownState);
 
     Event event = new TestEvent("refs/changes/01/1/meta");
@@ -332,6 +338,7 @@
             LOCAL_INSTANCE_ID,
             config,
             applyObjectsRefsFilter,
+            applyObjectsBannedCreateRefsFilter,
             shutdownState);
   }
 
@@ -420,6 +427,34 @@
   }
 
   @Test
+  public void shouldCallApplyObjectWhenCreatedRefNotMatchesApplyObjectBannedCreateRefsFilter()
+      throws Exception {
+    String bannedRef = "refs/heads/banned";
+    Event event = generateBatchCreateRefEvent("refs/heads/not-banned");
+    objectUnderTest.start();
+    lenient().when(applyObjectsBannedCreateRefsFilter.match(eq(bannedRef))).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient).callBatchSendObject(any(), any(), anyLong(), any());
+    verify(fetchRestApiClient, never()).callBatchFetch(any(), any(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallBatchFetchWhenCreatedRefMatchesApplyObjectBannedCreateRefsFilter()
+      throws Exception {
+    String bannedRef = "refs/heads/banned";
+    Event event = generateBatchCreateRefEvent("refs/heads/banned");
+    objectUnderTest.start();
+    when(applyObjectsBannedCreateRefsFilter.match(eq(bannedRef))).thenReturn(true);
+
+    objectUnderTest.onEvent(event);
+
+    verify(fetchRestApiClient).callBatchFetch(any(), any(), any());
+    verify(fetchRestApiClient, never()).callBatchSendObject(any(), any(), anyLong(), any());
+  }
+
+  @Test
   public void shouldFallbackToApplyAllParentObjectsWhenParentObjectIsMissingOnMetaRef()
       throws Exception {
     Event event = generateBatchRefUpdateEvent("refs/changes/01/1/meta");
@@ -536,6 +571,7 @@
             LOCAL_INSTANCE_ID,
             config,
             applyObjectsRefsFilter,
+            applyObjectsBannedCreateRefsFilter,
             shutdownState);
     Event event = generateBatchRefUpdateEvent("refs/multi-site/version");
     objectUnderTest.onEvent(event);
@@ -618,13 +654,21 @@
   }
 
   private BatchRefUpdateEvent generateBatchRefUpdateEvent(String... refs) {
+    return generateBatchRefEvent(OLD_OBJECT_ID, NEW_OBJECT_ID, refs);
+  }
+
+  private BatchRefUpdateEvent generateBatchCreateRefEvent(String... refs) {
+    return generateBatchRefEvent(ObjectId.zeroId().name(), NEW_OBJECT_ID, refs);
+  }
+
+  private BatchRefUpdateEvent generateBatchRefEvent(String oldRev, String newRev, 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.newRev = newRev;
+                  upd.oldRev = oldRev;
                   upd.project = PROJECT.get();
                   upd.refName = ref;
                   return upd;