Merge branch 'stable-3.11' into stable-3.12 * stable-3.11: Fix --remote option to replication start Add RoundRobin URL selection per remote Improve the wording to explain url matching Release-Notes: skip Change-Id: Ice84b2af7a2c5d794538fdb2c909942223f8f5c4
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java index 41a9a49..1c2bc94 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -158,6 +158,7 @@ private final DestinationConfiguration config; private final DynamicItem<EventDispatcher> eventDispatcher; private final Provider<ReplicationTasksStorage> replicationTasksStorage; + private final UrlDistributionStrategy.Instance urlDistributor; protected enum RetryReason { TRANSPORT_ERROR, @@ -200,6 +201,7 @@ this.replicationTasksStorage = rts; this.credentialsFactory = credentialsFactory; config = cfg; + urlDistributor = cfg.getUrlDistributionStrategy().newInstance(); ImmutableList<String> projects = cfg.getProjects(); int numStripes = projects.isEmpty() ? MAX_STRIPES : Math.min(projects.size(), MAX_STRIPES); @@ -800,6 +802,14 @@ return r; } + List<URIish> getDistributedUris(Project.NameKey project, String urlMatch) { + return getDistributedUris(getURIs(project, urlMatch)); + } + + List<URIish> getDistributedUris(List<URIish> candidates) { + return urlDistributor.select(candidates); + } + URIish getURI(URIish template, Project.NameKey project) throws URISyntaxException { return getURI(template, project, config.getRemoteNameStyle(), config.isSingleProjectMatch()); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java index 41ab4c4..e77b3cf 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
@@ -56,6 +56,7 @@ private final int slowLatencyThreshold; private final Supplier<Integer> pushBatchSize; private final ImmutableList<Pattern> excludedRefsPattern; + private final UrlDistributionStrategy urlDistributionStrategy; protected DestinationConfiguration(RemoteConfig remoteConfig, Config cfg) { this.remoteConfig = remoteConfig; @@ -122,6 +123,9 @@ return 0; }); excludedRefsPattern = getExcludedRefsPattern(cfg, name); + urlDistributionStrategy = + UrlDistributionStrategy.fromConfig( + cfg.getString("remote", name, "urlDistributionStrategy")); } @Override @@ -227,6 +231,11 @@ return excludedRefsPattern; } + @Override + public UrlDistributionStrategy getUrlDistributionStrategy() { + return urlDistributionStrategy; + } + private ImmutableList<Pattern> getExcludedRefsPattern(Config cfg, String name) { List<Pattern> patterns = new ArrayList<>(); for (String regex : cfg.getStringList("remote", name, "excludedRefsPattern")) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java index 471a408..82f33d7 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -99,6 +99,7 @@ } boolean adminURLUsed = false; + List<URIish> validUris = new ArrayList<>(); for (String url : config.getAdminUrls()) { if (Strings.isNullOrEmpty(url)) { @@ -128,15 +129,16 @@ continue; } } - uris.put(config, uri); + validUris.add(uri); adminURLUsed = true; } if (!adminURLUsed) { for (URIish uri : config.getURIs(projectName, "*")) { - uris.put(config, uri); + validUris.add(uri); } } + config.getDistributedUris(validUris).forEach(uri -> uris.put(config, uri)); } return uris; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java index 67d23b0..c05058b 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteConfiguration.java
@@ -152,4 +152,9 @@ default ImmutableList<Pattern> excludedRefsPattern() { return ImmutableList.of(); } + + /** Returns the URL distribution strategy configured for this remote. */ + default UrlDistributionStrategy getUrlDistributionStrategy() { + return UrlDistributionStrategy.ALL; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java index 7b6079a..5d7fd19 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -231,7 +231,7 @@ } } if (!refNamesToPush.isEmpty()) { - for (URIish uri : cfg.getURIs(project, urlMatch)) { + for (URIish uri : cfg.getDistributedUris(project, urlMatch)) { replicationTasksStorage.create( ReplicateRefUpdate.create( project.get(), refNamesToPush, uri, cfg.getRemoteConfigName()));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java index 507774f..92c619c 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
@@ -39,11 +39,15 @@ @Option(name = "--all", usage = "push all known projects") private boolean all; - @Option(name = "--url", metaVar = "PATTERN", usage = "pattern to match URL on") + @Option(name = "--url", metaVar = "SUBSTRING", usage = "substring URL must match (or * to match everything)") private String urlMatch; + private final Set<String> remotesToConsider = new HashSet<>(); + @Option(name = "--remote", metaVar = "REMOTE", usage = "name of remote to replicate to") - private Set<String> remotesToConsider = new HashSet<>(); + void addRemote(String remote) { + remotesToConsider.add(remote); + } @Option(name = "--wait", usage = "wait for replication to finish before exiting") private boolean wait;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java b/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java new file mode 100644 index 0000000..46183eb --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java
@@ -0,0 +1,84 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.replication; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.eclipse.jgit.transport.URIish; + +/** + * URL distribution strategy used when a remote has multiple configured URLs. + * + * <p>Each enum constant acts as a factory: call {@link #newInstance()} to obtain a stateful + * executor. Callers (e.g. {@link Destination}) hold the {@link Instance}, while the enum constant + * itself remains stateless and safe to use in equality checks. + * + * <p>Configured via {@code remote.NAME.urlDistribution} in {@code replication.config}. + */ +public enum UrlDistributionStrategy { + /** Push to all configured URLs. */ + ALL("all") { + @Override + public Instance newInstance() { + return candidates -> candidates; + } + }, + + /** + * Push to one URL at a time, rotating through the list on each push event. Particularly useful + * when multiple replica hosts share a single backend (likely via NFS): pushing to all URLs would + * cause redundant writes to the same underlying storage, while round-robin distributes load + * evenly and ensures each push is executed exactly once. + */ + ROUND_ROBIN("roundRobin") { + @Override + public Instance newInstance() { + final AtomicInteger index = new AtomicInteger(); + return candidates -> { + if (candidates.isEmpty()) { + return List.of(); + } + return List.of(candidates.get(Math.floorMod(index.getAndIncrement(), candidates.size()))); + }; + } + }; + + public final String configKey; + + UrlDistributionStrategy(String key) { + configKey = key; + } + + /** Creates a new stateful executor for this distribution strategy. */ + public abstract Instance newInstance(); + + /** + * Returns the distribution strategy for the given config value, or {@link #ALL} if the value is + * unrecognized or absent. + */ + public static UrlDistributionStrategy fromConfig(String value) { + return Arrays.stream(values()) + .filter(candidate -> candidate.configKey.equals(value)) + .findFirst() + .orElse(ALL); + } + + /** A stateful executor for a {@link UrlDistributionStrategy} strategy. */ + @FunctionalInterface + public interface Instance { + List<URIish> select(List<URIish> candidates); + } +}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index 67c201c..73b3805 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -612,6 +612,24 @@ Do not exclude any refs pattern by default. +remote.NAME.urlDistributionStrategy +: URL distribution strategy to use when a remote has multiple configured URLs. + Applies to both push URLs (`url`) and admin URLs (`adminUrl`). + + `all` (default) + : Push to all configured URLs simultaneously. This is the standard + replication behaviour. + + `roundRobin` + : Push to one URL at a time, rotating through the list on each push event. + Particularly useful in high-availability setups where multiple replica + hosts share a single NFS backend. Pushing to all URLs simultaneously + would cause redundant writes to the same underlying storage; round-robin + distributes load evenly and ensures each push is written exactly once. + Has no effect if only one URL is configured. + + Defaults to `all`. + Directory `replication` -------------------- The optional directory `$site_path/etc/replication` contains Git-style
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java index 646f915..c7b4174 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java
@@ -98,4 +98,20 @@ // then assertThat(actual).isEqualTo(globalPushBatchSize); } + + @Test + public void shouldDefaultUrlDistributionToAll() { + assertThat(objectUnderTest.getUrlDistributionStrategy()).isEqualTo(UrlDistributionStrategy.ALL); + } + + @Test + public void shouldSetUrlDistributionToRoundRobinWhenConfigured() { + // given + when(cfgMock.getString("remote", REMOTE, "urlDistributionStrategy")).thenReturn("roundRobin"); + objectUnderTest = new DestinationConfiguration(remoteConfigMock, cfgMock); + + // when / then + assertThat(objectUnderTest.getUrlDistributionStrategy()) + .isEqualTo(UrlDistributionStrategy.ROUND_ROBIN); + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java index 11b7718..436fb1c 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationDaemon.java
@@ -308,4 +308,10 @@ config.setBoolean("remote", remoteName, "replicateProjectDeletions", replicateProjectDeletion); config.save(); } + + protected void setUrlDistribution(String remoteName, UrlDistributionStrategy distribution) + throws IOException { + config.setString("remote", remoteName, "urlDistributionStrategy", distribution.configKey); + config.save(); + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java index 93b131d..b5aaed9 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -457,6 +457,102 @@ } @Test + public void shouldReplicateToAllUrlsByDefault() throws Exception { + Project.NameKey replica1Project = createTestProject(project + "replica1"); + Project.NameKey replica2Project = createTestProject(project + "replica2"); + + setReplicationDestination( + "foo", List.of("replica1", "replica2"), ALL_PROJECTS, TEST_REPLICATION_DELAY_SECONDS); + reloadConfig(); + + String newRef = "refs/heads/newForTest"; + createNewBranchWithoutPush("refs/heads/master", newRef); + + plugin + .getSysInjector() + .getInstance(ReplicationQueue.class) + .scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + // Wait for the push to land on both the refs + try (Repository r1 = repoManager.openRepository(replica1Project); + Repository r2 = repoManager.openRepository(replica2Project)) { + waitUntil(() -> checkedGetRef(r1, newRef) != null && checkedGetRef(r2, newRef) != null); + } + } + + @Test + public void shouldReplicateToOnlyOneUrlWhenRoundRobinEnabled() throws Exception { + Project.NameKey replica1Project = createTestProject(project + "replica1"); + Project.NameKey replica2Project = createTestProject(project + "replica2"); + + setReplicationDestination( + "foo", List.of("replica1", "replica2"), ALL_PROJECTS, TEST_REPLICATION_DELAY_SECONDS); + setUrlDistribution("foo", UrlDistributionStrategy.ROUND_ROBIN); + reloadConfig(); + + String newRef = "refs/heads/newForTest"; + createNewBranchWithoutPush("refs/heads/master", newRef); + + plugin + .getSysInjector() + .getInstance(ReplicationQueue.class) + .scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + // Wait for the push to land in at least one replica + try (Repository r1 = repoManager.openRepository(replica1Project); + Repository r2 = repoManager.openRepository(replica2Project)) { + waitUntil(() -> checkedGetRef(r1, newRef) != null || checkedGetRef(r2, newRef) != null); + + // Exactly one replica should have received the push + boolean r1HasRef = checkedGetRef(r1, newRef) != null; + boolean r2HasRef = checkedGetRef(r2, newRef) != null; + assertThat(r1HasRef ^ r2HasRef).isTrue(); + } + } + + @Test + public void shouldRotateAcrossUrlsOnConsecutivePushesWhenRoundRobinEnabled() throws Exception { + Project.NameKey replica1Project = createTestProject(project + "replica1"); + Project.NameKey replica2Project = createTestProject(project + "replica2"); + + setReplicationDestination( + "foo", List.of("replica1", "replica2"), ALL_PROJECTS, TEST_REPLICATION_DELAY_SECONDS); + setUrlDistribution("foo", UrlDistributionStrategy.ROUND_ROBIN); + reloadConfig(); + + String branch1 = "refs/heads/branch1"; + String branch2 = "refs/heads/branch2"; + createNewBranchWithoutPush("refs/heads/master", branch1); + + ReplicationQueue queue = plugin.getSysInjector().getInstance(ReplicationQueue.class); + + // First sync - goes to replica1 (index 0) + queue.scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + try (Repository r1 = repoManager.openRepository(replica1Project)) { + waitUntil(() -> checkedGetRef(r1, branch1) != null); + } + + // replica2 should not have received branch1 yet + try (Repository r2 = repoManager.openRepository(replica2Project)) { + assertThat(checkedGetRef(r2, branch1)).isNull(); + } + + // Second sync - goes to replica2 (index 1), includes branch1 and branch2 + createNewBranchWithoutPush("refs/heads/master", branch2); + queue.scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + try (Repository r2 = repoManager.openRepository(replica2Project)) { + waitUntil(() -> checkedGetRef(r2, branch1) != null && checkedGetRef(r2, branch2) != null); + } + + // replica1 should not have received branch2 (it was only in the second sync) + try (Repository r1 = repoManager.openRepository(replica1Project)) { + assertThat(checkedGetRef(r1, branch2)).isNull(); + } + } + + @Test public void shouldReplicateToMatchingRemote() throws Exception { Project.NameKey targetProject = createTestProject(project + "replica");