From c50bb2ed84d3cd079eff74ae4d30296b40bfc8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Thu, 9 Apr 2026 15:02:52 +0200 Subject: [PATCH 1/2] chore(spanner): add LatencyTracker interface and default implementation Adds an internal LatencyTracker interface and a default implementation that allows the client to track the latency of requests. This can be used for automatic replica selection and load balancing. --- java-spanner/.gitignore | 1 - .../spanner/spi/v1/EwmaLatencyTracker.java | 85 +++++++++++++++++ .../cloud/spanner/spi/v1/LatencyTracker.java | 52 +++++++++++ .../spi/v1/EwmaLatencyTrackerTest.java | 93 +++++++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) delete mode 100644 java-spanner/.gitignore create mode 100644 java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java create mode 100644 java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java create mode 100644 java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java 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/EwmaLatencyTracker.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java new file mode 100644 index 000000000000..8139cc402040 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java @@ -0,0 +1,85 @@ +/* + * 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.Preconditions; +import javax.annotation.concurrent.GuardedBy; + +/** + * Implementation of {@link LatencyTracker} using Exponentially Weighted Moving Average (EWMA). + * + *

Formula: $S_{i+1} = \alpha * new\_latency + (1 - \alpha) * S_i$ + * + *

This class is thread-safe. + */ +@InternalApi +@BetaApi +public class EwmaLatencyTracker implements LatencyTracker { + + public static final double DEFAULT_ALPHA = 0.05; + + private final double alpha; + private final Object lock = new Object(); + + @GuardedBy("lock") + private double score; + + @GuardedBy("lock") + private boolean initialized = false; + + /** Creates a new tracker with the default alpha value of 0.05. */ + public EwmaLatencyTracker() { + this(DEFAULT_ALPHA); + } + + /** + * Creates a new tracker with the specified alpha value. + * + * @param alpha the smoothing factor, must be in the range (0, 1] + */ + public EwmaLatencyTracker(double alpha) { + Preconditions.checkArgument(alpha > 0.0 && alpha <= 1.0, "alpha must be in (0, 1]"); + this.alpha = alpha; + } + + @Override + public double getScore() { + synchronized (lock) { + return initialized ? score : Double.MAX_VALUE; + } + } + + @Override + public void update(long latencyMillis) { + synchronized (lock) { + if (!initialized) { + score = latencyMillis; + initialized = true; + } else { + score = alpha * latencyMillis + (1 - alpha) * score; + } + } + } + + @Override + public void recordError(long penaltyMillis) { + // Treat the error as a sample with high latency (penalty) + update(penaltyMillis); + } +} diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java new file mode 100644 index 000000000000..fcb8bcea506f --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java @@ -0,0 +1,52 @@ +/* + * 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; + +/** + * Interface for tracking latency scores of Spanner servers. + * + *

Implementations must be thread-safe as instances may be shared across multiple concurrent + * operations. + */ +@InternalApi +@BetaApi +public interface LatencyTracker { + + /** + * Returns the current latency score. + * + * @return the latency score, where lower is better. + */ + double getScore(); + + /** + * Updates the latency score with a new observation. + * + * @param latencyMillis the observed latency in milliseconds. + */ + void update(long latencyMillis); + + /** + * Records an error and applies a latency penalty. + * + * @param penaltyMillis the penalty in milliseconds to apply. + */ + void recordError(long penaltyMillis); +} diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java new file mode 100644 index 000000000000..a43fbfcfdf2a --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java @@ -0,0 +1,93 @@ +/* + * 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.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class EwmaLatencyTrackerTest { + + @Test + public void testInitialization() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(); + tracker.update(100); + assertEquals(100.0, tracker.getScore(), 0.001); + } + + @Test + public void testUninitializedScore() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(); + assertEquals(Double.MAX_VALUE, tracker.getScore(), 0.001); + } + + @Test + public void testEwmaCalculation() { + double alpha = 0.5; + EwmaLatencyTracker tracker = new EwmaLatencyTracker(alpha); + + tracker.update(100); // Initial score = 100 + assertEquals(100.0, tracker.getScore(), 0.001); + + tracker.update(200); // Score = 0.5 * 200 + 0.5 * 100 = 150 + assertEquals(150.0, tracker.getScore(), 0.001); + + tracker.update(300); // Score = 0.5 * 300 + 0.5 * 150 = 225 + assertEquals(225.0, tracker.getScore(), 0.001); + } + + @Test + public void testDefaultAlpha() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(); + tracker.update(100); + tracker.update(200); + + double expected = + EwmaLatencyTracker.DEFAULT_ALPHA * 200 + (1 - EwmaLatencyTracker.DEFAULT_ALPHA) * 100; + assertEquals(expected, tracker.getScore(), 0.001); + } + + @Test + public void testRecordError() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(0.5); + tracker.update(100); + + tracker.recordError(10000); // Score = 0.5 * 10000 + 0.5 * 100 = 5050 + assertEquals(5050.0, tracker.getScore(), 0.001); + } + + @Test + public void testInvalidAlpha() { + assertThrows(IllegalArgumentException.class, () -> new EwmaLatencyTracker(0.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaLatencyTracker(1.1)); + assertThrows(IllegalArgumentException.class, () -> new EwmaLatencyTracker(-0.1)); + } + + @Test + public void testAlphaOne() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(1.0); + tracker.update(100); + assertEquals(100.0, tracker.getScore(), 0.001); + + tracker.update(200); + assertEquals(200.0, tracker.getScore(), 0.001); + } +} From 0d14d73bcd66c0fdfb3204ae805671ca04460be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Fri, 10 Apr 2026 13:04:27 +0200 Subject: [PATCH 2/2] chore(spanner): address review comments --- .../spanner/spi/v1/EwmaLatencyTracker.java | 20 +++++++++---- .../cloud/spanner/spi/v1/LatencyTracker.java | 9 +++--- .../spi/v1/EwmaLatencyTrackerTest.java | 28 ++++++++++++------- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java index 8139cc402040..0cb2331660f9 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java @@ -19,6 +19,8 @@ import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; import com.google.common.base.Preconditions; +import java.time.Duration; +import java.util.concurrent.TimeUnit; import javax.annotation.concurrent.GuardedBy; /** @@ -66,20 +68,28 @@ public double getScore() { } @Override - public void update(long latencyMillis) { + public void update(Duration latency) { + long latencyMicros; + try { + latencyMicros = TimeUnit.MICROSECONDS.convert(latency.toNanos(), TimeUnit.NANOSECONDS); + } catch (ArithmeticException e) { + // Duration is too large to fit in nanoseconds (292+ years). + // Use Long.MAX_VALUE to give it the lowest possible priority. + latencyMicros = Long.MAX_VALUE; + } synchronized (lock) { if (!initialized) { - score = latencyMillis; + score = latencyMicros; initialized = true; } else { - score = alpha * latencyMillis + (1 - alpha) * score; + score = alpha * latencyMicros + (1 - alpha) * score; } } } @Override - public void recordError(long penaltyMillis) { + public void recordError(Duration penalty) { // Treat the error as a sample with high latency (penalty) - update(penaltyMillis); + update(penalty); } } diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java index fcb8bcea506f..d7467853492d 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java @@ -18,6 +18,7 @@ import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; +import java.time.Duration; /** * Interface for tracking latency scores of Spanner servers. @@ -39,14 +40,14 @@ public interface LatencyTracker { /** * Updates the latency score with a new observation. * - * @param latencyMillis the observed latency in milliseconds. + * @param latency the observed latency. */ - void update(long latencyMillis); + void update(Duration latency); /** * Records an error and applies a latency penalty. * - * @param penaltyMillis the penalty in milliseconds to apply. + * @param penalty the penalty to apply. */ - void recordError(long penaltyMillis); + void recordError(Duration penalty); } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java index a43fbfcfdf2a..306628b9bdab 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java @@ -19,6 +19,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import java.time.Duration; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -29,7 +30,7 @@ public class EwmaLatencyTrackerTest { @Test public void testInitialization() { EwmaLatencyTracker tracker = new EwmaLatencyTracker(); - tracker.update(100); + tracker.update(Duration.ofNanos(100 * 1000)); assertEquals(100.0, tracker.getScore(), 0.001); } @@ -39,26 +40,33 @@ public void testUninitializedScore() { assertEquals(Double.MAX_VALUE, tracker.getScore(), 0.001); } + @Test + public void testOverflowScore() { + EwmaLatencyTracker tracker = new EwmaLatencyTracker(); + tracker.update(Duration.ofSeconds(Long.MAX_VALUE)); + assertEquals((double) Long.MAX_VALUE, tracker.getScore(), 0.001); + } + @Test public void testEwmaCalculation() { double alpha = 0.5; EwmaLatencyTracker tracker = new EwmaLatencyTracker(alpha); - tracker.update(100); // Initial score = 100 + tracker.update(Duration.ofNanos(100 * 1000)); // Initial score = 100 assertEquals(100.0, tracker.getScore(), 0.001); - tracker.update(200); // Score = 0.5 * 200 + 0.5 * 100 = 150 + tracker.update(Duration.ofNanos(200 * 1000)); // Score = 0.5 * 200 + 0.5 * 100 = 150 assertEquals(150.0, tracker.getScore(), 0.001); - tracker.update(300); // Score = 0.5 * 300 + 0.5 * 150 = 225 + tracker.update(Duration.ofNanos(300 * 1000)); // Score = 0.5 * 300 + 0.5 * 150 = 225 assertEquals(225.0, tracker.getScore(), 0.001); } @Test public void testDefaultAlpha() { EwmaLatencyTracker tracker = new EwmaLatencyTracker(); - tracker.update(100); - tracker.update(200); + tracker.update(Duration.ofNanos(100 * 1000)); + tracker.update(Duration.ofNanos(200 * 1000)); double expected = EwmaLatencyTracker.DEFAULT_ALPHA * 200 + (1 - EwmaLatencyTracker.DEFAULT_ALPHA) * 100; @@ -68,9 +76,9 @@ public void testDefaultAlpha() { @Test public void testRecordError() { EwmaLatencyTracker tracker = new EwmaLatencyTracker(0.5); - tracker.update(100); + tracker.update(Duration.ofNanos(100 * 1000)); - tracker.recordError(10000); // Score = 0.5 * 10000 + 0.5 * 100 = 5050 + tracker.recordError(Duration.ofNanos(10000 * 1000)); // Score = 0.5 * 10000 + 0.5 * 100 = 5050 assertEquals(5050.0, tracker.getScore(), 0.001); } @@ -84,10 +92,10 @@ public void testInvalidAlpha() { @Test public void testAlphaOne() { EwmaLatencyTracker tracker = new EwmaLatencyTracker(1.0); - tracker.update(100); + tracker.update(Duration.ofNanos(100 * 1000)); assertEquals(100.0, tracker.getScore(), 0.001); - tracker.update(200); + tracker.update(Duration.ofNanos(200 * 1000)); assertEquals(200.0, tracker.getScore(), 0.001); } }