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;