Merge branch 'stable-3.13' into stable-3.14 * stable-3.13: Retry failovers by applying url distribution strategy Add projectSharded URL selection per remote Change-Id: I8923c1620b4323411825d99bc5b1c0c35a1a34bb
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 54410f2..e05cc2b 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -139,6 +139,7 @@ boolean isRescheduled; boolean isFailed; RemoteRefUpdate.Status failedStatus; + URIish failoverTo; } private final ReplicationStateListener stateLog; @@ -604,9 +605,7 @@ // second one fails, it will also be rescheduled and then, // here, find out replication to its URI is already pending // for retry (blocking). - pendingPushOp.addRefBatches(pushOp.getRefs()); - pendingPushOp.addStates(pushOp.getStates()); - pushOp.removeStates(); + consolidateOnto(pendingPushOp, pushOp); } else { // The one pending is one that is NOT retrying, it was just @@ -623,9 +622,7 @@ pendingPushOp.canceledByReplication(); queue.pending.remove(uri); - pushOp.addRefBatches(pendingPushOp.getRefs()); - pushOp.addStates(pendingPushOp.getStates()); - pendingPushOp.removeStates(); + consolidateOnto(pushOp, pendingPushOp); } } @@ -646,11 +643,21 @@ : REJECTED_OTHER_REASON; status.isFailed = true; if (pushOp.setToRetry()) { - status.isRescheduled = true; - replicationTasksStorage.get().reset(pushOp); - @SuppressWarnings("unused") - ScheduledFuture<?> ignored2 = - pool.schedule(pushOp, config.getRetryDelay(), TimeUnit.MINUTES); + URIish nextUri = + urlDistributor.failover( + getURIs(pushOp.getProjectNameKey(), null), pushOp.getURI()); + if (!nextUri.equals(pushOp.getURI())) { + // Defer actual failoverTo until after this write lock is released + pushOp.canceledByReplication(); + queue.pending.remove(uri); + status.failoverTo = nextUri; + } else { + status.isRescheduled = true; + replicationTasksStorage.get().reset(pushOp); + @SuppressWarnings("unused") + ScheduledFuture<?> ignored2 = + pool.schedule(pushOp, config.getRetryDelay(), TimeUnit.MINUTES); + } } else { pushOp.canceledByReplication(); pushOp.retryDone(); @@ -671,6 +678,51 @@ if (status.isRescheduled) { postReplicationScheduledEvent(pushOp); } + if (status.failoverTo != null) { + failoverTo(pushOp, status.failoverTo); + } + } + + private void failoverTo(PushOne pushOp, URIish newUri) { + PushOne replacement = opFactory.create(pushOp.getProjectNameKey(), newUri); + replacement.addRefBatches(pushOp.getRefs()); + replacement.addStates(pushOp.getStates()); + replacement.setToRetryWithCount(pushOp.getRetryCount()); + pushOp.removeStates(); + + boolean installed = + stateLock.withWriteLock( + newUri, + () -> { + for (ReplicationTasksStorage.ReplicateRefUpdate u : + replacement.getReplicateRefUpdates()) { + replicationTasksStorage.get().create(u); + } + PushOne existing = getPendingPush(newUri); + if (existing != null) { + consolidateOnto(existing, replacement); + replicationTasksStorage.get().finish(pushOp); + return false; + } + replicationTasksStorage.get().finish(pushOp); + queue.pending.put(newUri, replacement); + @SuppressWarnings("unused") + ScheduledFuture<?> ignored = + pool.schedule(replacement, config.getRetryDelay(), TimeUnit.MINUTES); + return true; + }); + repLog.atInfo().log( + "Failover: replication for %s rerouted from %s to %s (retry %d)", + pushOp.getProjectNameKey(), pushOp.getURI(), newUri, replacement.getRetryCount()); + if (installed) { + postReplicationScheduledEvent(replacement); + } + } + + private static void consolidateOnto(PushOne into, PushOne from) { + into.addRefBatches(from.getRefs()); + into.addStates(from.getStates()); + from.removeStates(); } RunwayStatus requestRunway(PushOne op) { @@ -804,11 +856,11 @@ } List<URIish> getDistributedUris(Project.NameKey project, String urlMatch) { - return getDistributedUris(getURIs(project, urlMatch)); + return getDistributedUris(project, getURIs(project, urlMatch)); } - List<URIish> getDistributedUris(List<URIish> candidates) { - return urlDistributor.select(candidates); + List<URIish> getDistributedUris(Project.NameKey project, List<URIish> candidates) { + return urlDistributor.select(project, candidates); } URIish getURI(URIish template, Project.NameKey project) throws URISyntaxException {
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 4896dcb..f752a4a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -145,7 +145,7 @@ validUris.add(uri); } } - config.getDistributedUris(validUris).forEach(uri -> uris.put(config, uri)); + config.getDistributedUris(projectName, validUris).forEach(uri -> uris.put(config, uri)); } return uris; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java index 0e04e1e..600ce8c 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -263,11 +263,19 @@ } boolean setToRetry() { + return setToRetryWithCount(retryCount + 1); + } + + boolean setToRetryWithCount(int count) { retrying = true; - retryCount++; + retryCount = count; return maxRetries == 0 || retryCount <= maxRetries; } + int getRetryCount() { + return retryCount; + } + void retryDone() { this.retrying = false; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java b/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java index 46183eb..50d6504 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategy.java
@@ -14,7 +14,11 @@ package com.googlesource.gerrit.plugins.replication; +import com.google.common.collect.ImmutableList; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.jgit.transport.URIish; @@ -33,7 +37,7 @@ ALL("all") { @Override public Instance newInstance() { - return candidates -> candidates; + return (project, candidates) -> candidates; } }, @@ -41,17 +45,83 @@ * 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. + * evenly and ensures each push is executed exactly once. On transport failure {@link + * Instance#failover} hands the push over to the next URL in the rotation. */ ROUND_ROBIN("roundRobin") { @Override public Instance newInstance() { - final AtomicInteger index = new AtomicInteger(); - return candidates -> { - if (candidates.isEmpty()) { - return List.of(); + return new Instance() { + private final AtomicInteger index = new AtomicInteger(); + + @Override + public List<URIish> select(NameKey project, List<URIish> candidates) { + if (candidates.isEmpty()) { + return List.of(); + } + return List.of(candidates.get(Math.floorMod(index.getAndIncrement(), candidates.size()))); } - return List.of(candidates.get(Math.floorMod(index.getAndIncrement(), candidates.size()))); + + @Override + public URIish failover(List<URIish> candidates, URIish failed) { + if (candidates.size() < 2) { + return failed; + } + for (int attempt = 0; attempt < candidates.size(); attempt++) { + URIish next = candidates.get(Math.floorMod(index.getAndIncrement(), candidates.size())); + if (!next.equals(failed)) { + return next; + } + } + return failed; + } + }; + } + }, + + /** + * Push to exactly one URL, chosen by hashing the project name so that a given project always maps + * to the same URL. Like {@link #ROUND_ROBIN} this writes each push only once, which matters when + * the replica hosts share a single backend, but it additionally keeps consecutive updates for one + * project on the same URL. Because replication tasks are coalesced per (project, URI), rotating + * URLs would let successive updates for one project run as separate tasks racing against the same + * backend; pinning the project collapses them into a single task and keeps the receiving host's + * caches warm. + * + * <p>The candidates are sorted before indexing so that the mapping does not depend on the order + * in which the URLs happen to be configured. Together with {@link Project.NameKey#hashCode()}, + * which is the specified {@link String#hashCode()} of the project name, this makes every host + * reading the same config agree on the mapping. + */ + PROJECT_SHARDED("projectSharded") { + @Override + public Instance newInstance() { + return new Instance() { + @Override + public List<URIish> select(NameKey project, List<URIish> candidates) { + if (candidates.isEmpty()) { + return List.of(); + } + ImmutableList<URIish> sorted = sortedByUrl(candidates); + return List.of(sorted.get(Math.floorMod(project.hashCode(), sorted.size()))); + } + + @Override + public URIish failover(List<URIish> candidates, URIish failed) { + if (candidates.size() < 2) { + return failed; + } + ImmutableList<URIish> sorted = sortedByUrl(candidates); + int failedIndex = sorted.indexOf(failed); + if (failedIndex < 0) { + return failed; + } + return sorted.get((failedIndex + 1) % sorted.size()); + } + + private ImmutableList<URIish> sortedByUrl(List<URIish> candidates) { + return ImmutableList.sortedCopyOf(Comparator.comparing(URIish::toString), candidates); + } }; } }; @@ -79,6 +149,21 @@ /** A stateful executor for a {@link UrlDistributionStrategy} strategy. */ @FunctionalInterface public interface Instance { - List<URIish> select(List<URIish> candidates); + /** + * Selects the URLs to push to out of the candidates for the given project. + * + * @param project project being replicated, used by project-affine strategies. + * @param candidates URLs the project could be pushed to. + * @return the subset of candidates to push to. + */ + List<URIish> select(Project.NameKey project, List<URIish> candidates); + + /** + * If a push to any URI returned by {@link #select(Project.NameKey, List)} fails, {@link + * #failover(List, URIish)}} is invoked to select the next URI for retry. + */ + default URIish failover(List<URIish> candidates, URIish failed) { + return failed; + } } }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index fd3318a..083f4ee 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -687,6 +687,33 @@ 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. + On a transport error during retry, the next push attempt fails over to a + different URL in the rotation (bounded by `replicationRetry`), so a single + unreachable host does not block replication. Has no effect if only one URL + is configured. + + `projectSharded` + : Push to one URL, chosen so that a given project always maps to the same + URL. Like `roundRobin` each push is written exactly once, and load is + spread across the URLs, but consecutive updates for one project always + go to the same URL. + + Prefer this over `roundRobin` when replica hosts share a single backend. + Replication tasks are coalesced per (project, URL), so rotating URLs + makes successive updates for one project run as separate tasks that race + against the same backend; pinning a project to one URL collapses them + into a single task and keeps the receiving host's caches warm. + + The mapping is computed from a hash of the project name over the sorted + list of URLs, so it does not depend on the order in which the URLs are + configured and is identical on every host reading the same config. + Adding or removing a URL remaps projects across the remaining URLs. + + On a transport error during retry, the next push attempt fails over to a + different URL in the rotation (bounded by `replicationRetry`), so a single + unreachable host does not block replication. Has no effect if only one URL + is configured. + Has no effect if only one URL is configured. Defaults to `all`.
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 c7b4174..f5118dd 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/DestinationConfigurationTest.java
@@ -114,4 +114,16 @@ assertThat(objectUnderTest.getUrlDistributionStrategy()) .isEqualTo(UrlDistributionStrategy.ROUND_ROBIN); } + + @Test + public void shouldSetUrlDistributionToProjectShardedWhenConfigured() { + // given + when(cfgMock.getString("remote", REMOTE, "urlDistributionStrategy")) + .thenReturn("projectSharded"); + objectUnderTest = new DestinationConfiguration(remoteConfigMock, cfgMock); + + // when / then + assertThat(objectUnderTest.getUrlDistributionStrategy()) + .isEqualTo(UrlDistributionStrategy.PROJECT_SHARDED); + } }
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 d436f42..a267337 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -600,6 +600,81 @@ } @Test + public void shouldReplicateToOnlyOneUrlWhenProjectShardedEnabled() 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.PROJECT_SHARDED); + 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 shouldReuseSameUrlOnConsecutivePushesWhenProjectShardedEnabled() 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.PROJECT_SHARDED); + reloadConfig(); + + String branch1 = "refs/heads/branch1"; + String branch2 = "refs/heads/branch2"; + createNewBranchWithoutPush("refs/heads/master", branch1); + + ReplicationQueue queue = plugin.getSysInjector().getInstance(ReplicationQueue.class); + + queue.scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + // The project is pinned to one of the two replicas; discover which one. + Project.NameKey pinnedProject; + Project.NameKey otherProject; + try (Repository r1 = repoManager.openRepository(replica1Project); + Repository r2 = repoManager.openRepository(replica2Project)) { + waitUntil(() -> checkedGetRef(r1, branch1) != null || checkedGetRef(r2, branch1) != null); + + boolean replica1IsPinned = checkedGetRef(r1, branch1) != null; + assertThat(replica1IsPinned ^ (checkedGetRef(r2, branch1) != null)).isTrue(); + pinnedProject = replica1IsPinned ? replica1Project : replica2Project; + otherProject = replica1IsPinned ? replica2Project : replica1Project; + } + + // Second sync must land on the same replica rather than rotating to the other one + createNewBranchWithoutPush("refs/heads/master", branch2); + queue.scheduleFullSync(project, null, new ReplicationState(NO_OP), true); + + try (Repository pinnedRepo = repoManager.openRepository(pinnedProject)) { + waitUntil(() -> checkedGetRef(pinnedRepo, branch2) != null); + } + + try (Repository otherRepo = repoManager.openRepository(otherProject)) { + assertThat(checkedGetRef(otherRepo, branch1)).isNull(); + assertThat(checkedGetRef(otherRepo, branch2)).isNull(); + } + } + + @Test public void shouldReplicateToMatchingRemote() throws Exception { Project.NameKey targetProject = createTestProject(project + "replica");
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategyTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategyTest.java new file mode 100644 index 0000000..2ded31e --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/UrlDistributionStrategyTest.java
@@ -0,0 +1,170 @@ +// 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 static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.entities.Project; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import org.eclipse.jgit.transport.URIish; +import org.junit.Test; + +public class UrlDistributionStrategyTest { + private static final Project.NameKey PROJECT = Project.nameKey("some/project"); + private static final Project.NameKey OTHER_PROJECT = Project.nameKey("some/other/project"); + + private static ImmutableList<URIish> uris(String... hosts) throws URISyntaxException { + ImmutableList.Builder<URIish> uris = ImmutableList.builder(); + for (String host : hosts) { + uris.add(new URIish("git://" + host + "/${name}.git")); + } + return uris.build(); + } + + @Test + public void shouldResolveConfigKeys() { + assertThat(UrlDistributionStrategy.fromConfig("all")).isEqualTo(UrlDistributionStrategy.ALL); + assertThat(UrlDistributionStrategy.fromConfig("roundRobin")) + .isEqualTo(UrlDistributionStrategy.ROUND_ROBIN); + assertThat(UrlDistributionStrategy.fromConfig("projectSharded")) + .isEqualTo(UrlDistributionStrategy.PROJECT_SHARDED); + } + + @Test + public void shouldFallBackToAllForUnknownConfigKey() { + assertThat(UrlDistributionStrategy.fromConfig("bogus")).isEqualTo(UrlDistributionStrategy.ALL); + assertThat(UrlDistributionStrategy.fromConfig(null)).isEqualTo(UrlDistributionStrategy.ALL); + } + + @Test + public void allShouldSelectEveryCandidate() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2", "replica3"); + UrlDistributionStrategy.Instance distributor = UrlDistributionStrategy.ALL.newInstance(); + + assertThat(distributor.select(PROJECT, candidates)).isEqualTo(candidates); + } + + @Test + public void roundRobinShouldRotateOnConsecutiveSelections() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.ROUND_ROBIN.newInstance(); + + assertThat(distributor.select(PROJECT, candidates)).containsExactly(candidates.get(0)); + assertThat(distributor.select(PROJECT, candidates)).containsExactly(candidates.get(1)); + assertThat(distributor.select(PROJECT, candidates)).containsExactly(candidates.get(0)); + } + + @Test + public void roundRobinShouldRotateRegardlessOfProject() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.ROUND_ROBIN.newInstance(); + + assertThat(distributor.select(PROJECT, candidates)).containsExactly(candidates.get(0)); + assertThat(distributor.select(OTHER_PROJECT, candidates)).containsExactly(candidates.get(1)); + } + + @Test + public void projectShardedShouldSelectExactlyOneCandidate() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2", "replica3"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance(); + + List<URIish> selected = distributor.select(PROJECT, candidates); + + assertThat(selected).hasSize(1); + assertThat(candidates).containsAtLeastElementsIn(selected); + } + + @Test + public void projectShardedShouldPinProjectToSameCandidateAcrossSelections() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2", "replica3"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance(); + + List<URIish> first = distributor.select(PROJECT, candidates); + for (int i = 0; i < 10; i++) { + assertThat(distributor.select(PROJECT, candidates)).isEqualTo(first); + } + } + + @Test + public void projectShardedShouldAgreeAcrossInstances() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2", "replica3"); + + List<URIish> first = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance().select(PROJECT, candidates); + List<URIish> second = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance().select(PROJECT, candidates); + + assertThat(second).isEqualTo(first); + } + + @Test + public void projectShardedShouldIgnoreCandidateOrdering() throws Exception { + ImmutableList<URIish> candidates = + uris("replica1", "replica2", "replica3", "replica4", "replica5"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance(); + + List<URIish> expected = distributor.select(PROJECT, candidates); + + List<URIish> shuffled = new ArrayList<>(candidates); + Random random = new Random(42); + for (int i = 0; i < 20; i++) { + Collections.shuffle(shuffled, random); + assertThat(distributor.select(PROJECT, shuffled)).isEqualTo(expected); + } + } + + @Test + public void projectShardedShouldSpreadProjectsOverAllCandidates() throws Exception { + ImmutableList<URIish> candidates = uris("replica1", "replica2", "replica3"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance(); + + Set<URIish> selected = new HashSet<>(); + for (int i = 0; i < 100; i++) { + selected.addAll(distributor.select(Project.nameKey("project" + i), candidates)); + } + + assertThat(selected).containsExactlyElementsIn(candidates); + } + + @Test + public void projectShardedShouldSelectOnlyCandidateWhenSingleUrlConfigured() throws Exception { + ImmutableList<URIish> candidates = uris("replica1"); + UrlDistributionStrategy.Instance distributor = + UrlDistributionStrategy.PROJECT_SHARDED.newInstance(); + + assertThat(distributor.select(PROJECT, candidates)).isEqualTo(candidates); + assertThat(distributor.select(OTHER_PROJECT, candidates)).isEqualTo(candidates); + } + + @Test + public void shouldSelectNothingWhenThereAreNoCandidates() { + for (UrlDistributionStrategy strategy : UrlDistributionStrategy.values()) { + assertThat(strategy.newInstance().select(PROJECT, List.of())).isEmpty(); + } + } +}