diff --git a/java-spanner/.gitignore b/java-spanner/.gitignore deleted file mode 100644 index 722d5e71d93c..000000000000 --- a/java-spanner/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.vscode diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelector.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelector.java new file mode 100644 index 000000000000..c7cc2012a615 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelector.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.spanner.spi.v1; + +import com.google.api.core.BetaApi; +import com.google.api.core.InternalApi; +import com.google.common.base.MoreObjects; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; + +/** Implementation of {@link ReplicaSelector} using the "Power of 2 Random Choices" strategy. */ +@InternalApi +@BetaApi +public class PowerOfTwoReplicaSelector implements ReplicaSelector { + + @Override + public ChannelEndpoint select( + List candidates, Function scoreLookup) { + if (candidates == null || candidates.isEmpty()) { + return null; + } + if (candidates.size() == 1) { + return candidates.get(0); + } + + Random random = ThreadLocalRandom.current(); + int index1 = random.nextInt(candidates.size()); + int index2 = random.nextInt(candidates.size() - 1); + if (index2 >= index1) { + index2++; + } + + ChannelEndpoint c1 = candidates.get(index1); + ChannelEndpoint c2 = candidates.get(index2); + + Double score1 = scoreLookup.apply(c1); + Double score2 = scoreLookup.apply(c2); + + // Handle null scores by treating them as Double.MAX_VALUE (lowest priority) + double s1 = MoreObjects.firstNonNull(score1, Double.MAX_VALUE); + double s2 = MoreObjects.firstNonNull(score2, Double.MAX_VALUE); + + return s1 <= s2 ? c1 : c2; + } +} diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ReplicaSelector.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ReplicaSelector.java new file mode 100644 index 000000000000..de4f58e50f1e --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/ReplicaSelector.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.spanner.spi.v1; + +import com.google.api.core.BetaApi; +import com.google.api.core.InternalApi; +import java.util.List; +import java.util.function.Function; + +/** Interface for selecting a replica from a list of candidates. */ +@InternalApi +@BetaApi +public interface ReplicaSelector { + + /** + * Selects a replica from the given list of candidates. + * + * @param candidates the list of eligible candidates. + * @param scoreLookup a function to look up the latency score for a candidate. + * @return the selected candidate, or null if the list is empty. + */ + ChannelEndpoint select( + List candidates, Function scoreLookup); +} diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelectorTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelectorTest.java new file mode 100644 index 000000000000..424efb363df6 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/PowerOfTwoReplicaSelectorTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Google LLC + * + * 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.google.cloud.spanner.spi.v1; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class PowerOfTwoReplicaSelectorTest { + + private static class TestEndpoint implements ChannelEndpoint { + private final String address; + + TestEndpoint(String address) { + this.address = address; + } + + @Override + public String getAddress() { + return address; + } + + @Override + public boolean isHealthy() { + return true; + } + + @Override + public boolean isTransientFailure() { + return false; + } + + @Override + public io.grpc.ManagedChannel getChannel() { + return null; + } + } + + @Test + public void testEmptyList() { + PowerOfTwoReplicaSelector selector = new PowerOfTwoReplicaSelector(); + assertNull(selector.select(null, endpoint -> 1.0)); + assertNull(selector.select(Arrays.asList(), endpoint -> 1.0)); + } + + @Test + public void testSingleElement() { + PowerOfTwoReplicaSelector selector = new PowerOfTwoReplicaSelector(); + ChannelEndpoint endpoint = new TestEndpoint("a"); + assertEquals(endpoint, selector.select(Arrays.asList(endpoint), e -> 1.0)); + } + + @Test + public void testTwoElementsPicksBetter() { + PowerOfTwoReplicaSelector selector = new PowerOfTwoReplicaSelector(); + ChannelEndpoint better = new TestEndpoint("better"); + ChannelEndpoint worse = new TestEndpoint("worse"); + + Map scores = new HashMap<>(); + scores.put(better, 10.0); + scores.put(worse, 20.0); + + List candidates = Arrays.asList(better, worse); + + for (int i = 0; i < 100; i++) { + assertEquals(better, selector.select(candidates, scores::get)); + } + } + + @Test + public void testThreeElementsNeverPicksWorst() { + PowerOfTwoReplicaSelector selector = new PowerOfTwoReplicaSelector(); + ChannelEndpoint best = new TestEndpoint("best"); + ChannelEndpoint middle = new TestEndpoint("middle"); + ChannelEndpoint worst = new TestEndpoint("worst"); + + Map scores = new HashMap<>(); + scores.put(best, 10.0); + scores.put(middle, 20.0); + scores.put(worst, 30.0); + + List candidates = Arrays.asList(best, middle, worst); + + for (int i = 0; i < 100; i++) { + ChannelEndpoint selected = selector.select(candidates, scores::get); + assertTrue("Should not pick worst", selected != worst); + } + } + + @Test + public void testNullScoresTreatedAsMax() { + PowerOfTwoReplicaSelector selector = new PowerOfTwoReplicaSelector(); + ChannelEndpoint withScore = new TestEndpoint("withScore"); + ChannelEndpoint withoutScore = new TestEndpoint("withoutScore"); + + Map scores = new HashMap<>(); + scores.put(withScore, 100.0); + + List candidates = Arrays.asList(withScore, withoutScore); + + for (int i = 0; i < 100; i++) { + assertEquals(withScore, selector.select(candidates, scores::get)); + } + } +}