From 7f0e04e015a4456b8c893d27f03022d82aa37fe1 Mon Sep 17 00:00:00 2001 From: Dongie Agnir Date: Tue, 14 Jul 2026 13:37:58 -0700 Subject: [PATCH] Add acquireAsync This commit replaces tryAcquire with acquireAsync to better support async codepaths. acquireAsync returns a CompletableFuture that is completed when a token is successfully acquired; unlike tryAcquire, the caller is not expected to do any of their own waiting once future is complete. --- core/retries/pom.xml | 5 + .../DefaultAdaptiveRetryStrategy.java | 7 +- .../ratelimiter/RateLimiterTokenBucket.java | 170 ++++++++++++++---- .../RateLimiterTokenBucketStore.java | 20 ++- .../RateLimiterTokenBucketStoreTest.java | 37 ++++ .../RateLimiterTokenBucketTest.java | 121 +++++++++++-- 6 files changed, 311 insertions(+), 49 deletions(-) create mode 100644 core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStoreTest.java diff --git a/core/retries/pom.xml b/core/retries/pom.xml index aba7d96cd5d8..623bd4a00e6a 100644 --- a/core/retries/pom.xml +++ b/core/retries/pom.xml @@ -70,5 +70,10 @@ assertj-core test + + org.mockito + mockito-core + test + diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java index c52c7859526f..e63e683a18ab 100644 --- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java +++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java @@ -43,15 +43,12 @@ public final class DefaultAdaptiveRetryStrategy @Override protected Duration computeInitialBackoff(AcquireInitialTokenRequest request) { - RateLimiterTokenBucket bucket = rateLimiterTokenBucketStore.tokenBucketForScope(request.scope()); - return bucket.tryAcquire().delay(); + throw new UnsupportedOperationException("TODO"); } @Override protected Duration computeBackoff(RefreshRetryTokenRequest request, DefaultRetryToken token) { - Duration backoff = super.computeBackoff(request, token); - RateLimiterTokenBucket bucket = rateLimiterTokenBucketStore.tokenBucketForScope(token.scope()); - return backoff.plus(bucket.tryAcquire().delay()); + throw new UnsupportedOperationException("TODO"); } @Override diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucket.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucket.java index 2d8743af9924..3834906f5057 100644 --- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucket.java +++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucket.java @@ -16,42 +16,127 @@ package software.amazon.awssdk.retries.internal.ratelimiter; import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.utils.SdkAutoCloseable; /** * The {@link RateLimiterTokenBucket} keeps track of past throttling responses and adapts to slow down the send rate to adapt to - * the service. It does this by suggesting a delay amount as result of a {@link #tryAcquire()} call. Callers must update its - * internal state by calling {@link #updateRateAfterThrottling()} when getting a throttling response or - * {@link #updateRateAfterSuccess()} when getting successful response. + * the service. It does this by delaying the completion of the future returned by {@link #acquireAsync()} until the requested + * capacity is available. Callers must update its internal state by calling {@link #updateRateAfterThrottling()} when getting a + * throttling response or {@link #updateRateAfterSuccess()} when getting successful response. * - *

This class is thread-safe, its internal current state is kept in the inner class {@link PersistentState} which is stored - * using an {@link AtomicReference}. This class is converted to {@link TransientState} when the state needs to be mutated and - * converted back to a {@link PersistentState} and stored using {@link AtomicReference#compareAndSet(Object, Object)}. + *

This class is thread-safe. * *

The algorithm used is adapted from the network congestion avoidance algorithm * CUBIC. */ @SdkInternalApi -public class RateLimiterTokenBucket { +@ThreadSafe +public class RateLimiterTokenBucket implements SdkAutoCloseable { + // Thread used for capacity waiting and notifying. + private final ScheduledExecutorService scheduler; + // the collection of futures returned to threads currently waiting for capacity. Futures are completed in FIFO order. + private final Deque> waiting = new ArrayDeque<>(); private final AtomicReference stateReference; private final RateLimiterClock clock; + private final Object lock = new Object(); - RateLimiterTokenBucket(RateLimiterClock clock) { + private boolean notifierRunning = false; + + RateLimiterTokenBucket(RateLimiterClock clock, ScheduledExecutorService scheduler) { this.clock = clock; + this.scheduler = scheduler; this.stateReference = new AtomicReference<>(new PersistentState()); } + @Override + public void close() { + scheduler.shutdownNow(); + } + /** - * Acquire tokens from the bucket. If the bucket contains enough capacity to satisfy the request, this method will return in - * {@link RateLimiterAcquireResponse#delay()} a {@link Duration#ZERO} value, otherwise it will return the amount of time the - * callers need to wait until enough tokens are refilled. + * Acquire a token from the bucket. + * + * @return A future that is completed when the requested amount is acquired from this bucket. */ - public RateLimiterAcquireResponse tryAcquire() { - StateUpdate update = updateState(ts -> ts.tokenBucketAcquire(clock, 1.0)); - return RateLimiterAcquireResponse.create(update.result); + public CompletableFuture acquireAsync() { + synchronized (lock) { + CompletableFuture future = new CompletableFuture<>(); + if (!notifierRunning) { + waiting.add(future); + boolean scheduled = scheduleOrFail(this::doNotify, Duration.ZERO, future); + if (scheduled) { + notifierRunning = true; + } else { + waiting.pop(); + } + } + return future; + } + } + + public void doNotify() { + synchronized (lock) { + while (true) { + CompletableFuture w = waiting.poll(); + if (w == null) { + notifierRunning = false; + return; + } + + PersistentState persistentState = stateReference.get(); + TransientState ts = persistentState.toTransient(); + TransientState.AcquireResult acquireResult = ts.tokenBucketAcquire(clock, 1.0); + stateReference.set(ts.toPersistent()); + + // Not enough capacity. Try again later when enough time has passed to refill the bucket at the current rate. + if (!acquireResult.isSuccessful()) { + waiting.push(w); + + if (!scheduleOrFail(this::doNotify, acquireResult.refillWait(), w)) { + waiting.pop(); + return; + } + } + + // Acquire was successful, signal the waiting thread. + w.complete(null); + } + } + } + + @SdkTestInternalApi + Deque> waiting() { + return waiting; + } + + private void schedule(Runnable command, Duration d) { + scheduler.schedule(command, d.toMillis(), TimeUnit.MILLISECONDS); + } + + /** + * @return true if schedule was successful, false otherwise. + */ + private boolean scheduleOrFail(Runnable command, Duration d, CompletableFuture future) { + try { + schedule(command, d); + return true; + } catch (Throwable t) { + RuntimeException e = new RuntimeException("Unable to initiate token acquire", t); + future.completeExceptionally(e); + } + return false; } /** @@ -95,17 +180,20 @@ private StateUpdate consumeState(Consumer mutator) { * retried until succeeded. */ private StateUpdate updateState(Function mutator) { - PersistentState current; - PersistentState updated; - T result; - do { - current = stateReference.get(); - TransientState transientState = current.toTransient(); - result = mutator.apply(transientState); - updated = transientState.toPersistent(); - } while (!stateReference.compareAndSet(current, updated)); - - return new StateUpdate<>(updated, result); + synchronized (lock) { + PersistentState current; + PersistentState updated; + T result; + do { + current = stateReference.get(); + TransientState transientState = current.toTransient(); + result = mutator.apply(transientState); + updated = transientState.toPersistent(); + } while (!stateReference.compareAndSet(current, updated)); + + + return new StateUpdate<>(updated, result); + } } static class StateUpdate { @@ -163,17 +251,39 @@ PersistentState toPersistent() { * a {@link Duration#ZERO} value, otherwise it will return the amount of time the callers need to wait until enough tokens * are refilled. */ - Duration tokenBucketAcquire(RateLimiterClock clock, double amount) { + AcquireResult tokenBucketAcquire(RateLimiterClock clock, double amount) { if (!this.enabled) { - return Duration.ZERO; + return new AcquireResult(true, Duration.ZERO); } refill(clock); - double waitTime = 0.0; if (this.currentCapacity < amount) { - waitTime = (amount - this.currentCapacity) / this.fillRate; + double diff = amount - currentCapacity; + this.currentCapacity = 0; + double waitTime = diff / this.fillRate; + double waitTimeMs = waitTime * 1_000.0; + Duration duration = Duration.ofMillis((long) Math.ceil(waitTimeMs)); + return new AcquireResult(false, duration); } this.currentCapacity -= amount; - return Duration.ofNanos((long) (waitTime * 1_000_000_000.0)); + return new AcquireResult(true, Duration.ZERO); + } + + private static class AcquireResult { + final boolean successful; + final Duration refillWait; + + public AcquireResult(boolean successful, Duration refillWait) { + this.successful = successful; + this.refillWait = refillWait; + } + + public boolean isSuccessful() { + return successful; + } + + public Duration refillWait() { + return refillWait; + } } /** diff --git a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStore.java b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStore.java index 303e6d61d7f5..680c3b51f31e 100644 --- a/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStore.java +++ b/core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStore.java @@ -15,8 +15,10 @@ package software.amazon.awssdk.retries.internal.ratelimiter; +import java.util.concurrent.ScheduledExecutorService; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.ToBuilderIgnoreField; +import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; @@ -27,19 +29,27 @@ */ @SdkInternalApi public final class RateLimiterTokenBucketStore - implements ToCopyableBuilder { + implements ToCopyableBuilder, SdkAutoCloseable { private static final int MAX_ENTRIES = 128; private static final RateLimiterClock DEFAULT_CLOCK = new SystemClock(); private final LruCache scopeToTokenBucket; private final RateLimiterClock clock; + private final ScheduledExecutorService scheduler; private RateLimiterTokenBucketStore(Builder builder) { this.clock = Validate.paramNotNull(builder.clock, "clock"); - this.scopeToTokenBucket = LruCache.builder(x -> new RateLimiterTokenBucket(clock)) + this.scheduler = Validate.paramNotNull(builder.scheduler, "scheduler"); + this.scopeToTokenBucket = LruCache.builder( + x -> new RateLimiterTokenBucket(clock, scheduler)) .maxSize(MAX_ENTRIES) .build(); } + @Override + public void close() { + scheduler.shutdownNow(); + } + public RateLimiterTokenBucket tokenBucketForScope(String scope) { return scopeToTokenBucket.get(scope); } @@ -56,6 +66,7 @@ public static RateLimiterTokenBucketStore.Builder builder() { public static class Builder implements CopyableBuilder { private RateLimiterClock clock; + private ScheduledExecutorService scheduler; Builder() { this.clock = DEFAULT_CLOCK; @@ -70,6 +81,11 @@ public Builder clock(RateLimiterClock clock) { return this; } + public Builder scheduler(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + return this; + } + @Override public RateLimiterTokenBucketStore build() { return new RateLimiterTokenBucketStore(this); diff --git a/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStoreTest.java b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStoreTest.java new file mode 100644 index 000000000000..5c6ad377d3ee --- /dev/null +++ b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStoreTest.java @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.retries.internal.ratelimiter; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.ScheduledExecutorService; +import org.junit.jupiter.api.Test; + +public class RateLimiterTokenBucketStoreTest { + @Test + void close_closesScheduler() { + ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); + RateLimiterTokenBucketStore store = RateLimiterTokenBucketStore.builder() + .clock(new SystemClock()) + .scheduler(mockScheduler) + .build(); + + store.close(); + + verify(mockScheduler).shutdownNow(); + } +} diff --git a/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketTest.java b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketTest.java index 14021ebde812..9bea5a4dc787 100644 --- a/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketTest.java +++ b/core/retries/src/test/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketTest.java @@ -16,44 +16,132 @@ package software.amazon.awssdk.retries.internal.ratelimiter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.within; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; import java.util.Arrays; import java.util.Collection; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.api.Test; class RateLimiterTokenBucketTest { private static MutableClock clock = null; - private static RateLimiterTokenBucket tokenBucket = null; private static final double EPSILON = 0.0001; + private static ScheduledExecutorService scheduler = null; + private static RateLimiterTokenBucket tokenBucket = null; @BeforeAll static void setup() { clock = new MutableClock(); - tokenBucket = new RateLimiterTokenBucket(clock); + scheduler = mock(ScheduledExecutorService.class); + tokenBucket = new RateLimiterTokenBucket(clock, scheduler); } - @ParameterizedTest - @MethodSource("parameters") - void testCase(TestCase testCase) { + @Test + void acquireAsync_notifierNotRunning_schedules() { + ScheduledExecutorService testScheduler = mock(ScheduledExecutorService.class); + RateLimiterTokenBucket testBucket = new RateLimiterTokenBucket(new MutableClock(), testScheduler); + + testBucket.acquireAsync(); + verify(testScheduler).schedule(any(Runnable.class), eq(0L), any(TimeUnit.class)); + } + + @Test + void acquireAsync_notifierRunning_doesNotSchedule() { + ScheduledExecutorService testScheduler = mock(ScheduledExecutorService.class); + RateLimiterTokenBucket testBucket = new RateLimiterTokenBucket(new MutableClock(), testScheduler); + testBucket.acquireAsync(); + verify(testScheduler).schedule(any(Runnable.class), eq(0L), any(TimeUnit.class)); + + testBucket.acquireAsync(); + verifyNoMoreInteractions(testScheduler); + } + + @Test + void acquireAsync_notifierNotRunning_schedulerThrows_completesFuture() { + ScheduledExecutorService testScheduler = mock(ScheduledExecutorService.class); + RateLimiterTokenBucket testBucket = new RateLimiterTokenBucket(new MutableClock(), testScheduler); + when(testScheduler.schedule(any(Runnable.class), eq(0L), any(TimeUnit.class))) + .thenThrow(new RejectedExecutionException("rejected")); + + assertThatThrownBy(testBucket.acquireAsync()::join).satisfies(t -> { + Throwable cause = t.getCause(); + assertThat(cause).isExactlyInstanceOf(RuntimeException.class); + assertThat(cause).hasMessage("Unable to initiate token acquire"); + assertThat(cause.getCause()).hasMessage("rejected"); + }); + + assertThat(testBucket.waiting()).isEmpty(); + } + + @Test + void acquireAsync_notifierRunning_schedulerThrows_completesFuture() { + ScheduledExecutorService testScheduler = mock(ScheduledExecutorService.class); + RateLimiterTokenBucket testBucket = new RateLimiterTokenBucket(new MutableClock(), testScheduler); + + // Enable the bucket + testBucket.updateRateAfterThrottling(); + + when(testScheduler.schedule(any(Runnable.class), anyLong(), any())) + .thenAnswer(i -> { + Runnable command = i.getArgument(0); + Long delay = i.getArgument(1); + if (delay == 0L) { + command.run(); + return null; + } + throw new RejectedExecutionException("rejected"); + }); + + assertThatThrownBy(testBucket.acquireAsync()::join).satisfies(t -> { + Throwable cause = t.getCause(); + assertThat(cause).isExactlyInstanceOf(RuntimeException.class); + assertThat(cause).hasMessage("Unable to initiate token acquire"); + assertThat(cause.getCause()).hasMessage("rejected"); + }); + + assertThat(testBucket.waiting()).isEmpty(); + } + + @Test + void sendingRateEndToEndTest() { + for (TestCase sendingRateTestCase : sendingRateTestCases()) { + assertSendingRateTestCase(sendingRateTestCase); + } + } + + void assertSendingRateTestCase(TestCase testCase) { clock.setCurrent(testCase.givenTimestamp); RateLimiterUpdateResponse res; - tokenBucket.tryAcquire(); + if (testCase.throttleResponse) { res = tokenBucket.updateRateAfterThrottling(); } else { res = tokenBucket.updateRateAfterSuccess(); } double measuredTxRate = res.measuredTxRate(); - assertThat(measuredTxRate).isCloseTo(testCase.expectMeasuredTxRate, within(EPSILON)); + assertThat(measuredTxRate) + .as("%s: Measured TX rate", testCase) + .isCloseTo(testCase.expectMeasuredTxRate, within(EPSILON)); double fillRate = res.fillRate(); - assertThat(fillRate).isCloseTo(testCase.expectFillRate, within(EPSILON)); + assertThat(fillRate) + .as("%s: Fill rate", testCase) + .isCloseTo(testCase.expectFillRate, within(EPSILON)); } - - static Collection parameters() { + static Collection sendingRateTestCases() { + // Note: Test cases are not independent. Each case depends on the state of the bucket being correctly updated from the + // previous test. return Arrays.asList( new TestCase() .givenSuccessResponse() @@ -174,6 +262,15 @@ TestCase expectFillRate(double expectFillRate) { return this; } + @Override + public String toString() { + return "TestCase{" + + "throttleResponse=" + throttleResponse + + ", givenTimestamp=" + givenTimestamp + + ", expectMeasuredTxRate=" + expectMeasuredTxRate + + ", expectFillRate=" + expectFillRate + + '}'; + } } static class MutableClock implements RateLimiterClock {