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..0cb2331660f9 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTracker.java @@ -0,0 +1,95 @@ +/* + * 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 java.time.Duration; +import java.util.concurrent.TimeUnit; +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(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 = latencyMicros; + initialized = true; + } else { + score = alpha * latencyMicros + (1 - alpha) * score; + } + } + } + + @Override + public void recordError(Duration penalty) { + // Treat the error as a sample with high latency (penalty) + 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 new file mode 100644 index 000000000000..d7467853492d --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/LatencyTracker.java @@ -0,0 +1,53 @@ +/* + * 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.time.Duration; + +/** + * 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 latency the observed latency. + */ + void update(Duration latency); + + /** + * Records an error and applies a latency penalty. + * + * @param penalty the penalty to apply. + */ + 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 new file mode 100644 index 000000000000..306628b9bdab --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/EwmaLatencyTrackerTest.java @@ -0,0 +1,101 @@ +/* + * 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 java.time.Duration; +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(Duration.ofNanos(100 * 1000)); + 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 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(Duration.ofNanos(100 * 1000)); // Initial score = 100 + assertEquals(100.0, tracker.getScore(), 0.001); + + tracker.update(Duration.ofNanos(200 * 1000)); // Score = 0.5 * 200 + 0.5 * 100 = 150 + assertEquals(150.0, tracker.getScore(), 0.001); + + 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(Duration.ofNanos(100 * 1000)); + tracker.update(Duration.ofNanos(200 * 1000)); + + 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(Duration.ofNanos(100 * 1000)); + + tracker.recordError(Duration.ofNanos(10000 * 1000)); // 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(Duration.ofNanos(100 * 1000)); + assertEquals(100.0, tracker.getScore(), 0.001); + + tracker.update(Duration.ofNanos(200 * 1000)); + assertEquals(200.0, tracker.getScore(), 0.001); + } +}