Skip to content

Commit 7f0e04e

Browse files
committed
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.
1 parent 067c192 commit 7f0e04e

6 files changed

Lines changed: 311 additions & 49 deletions

File tree

core/retries/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,10 @@
7070
<artifactId>assertj-core</artifactId>
7171
<scope>test</scope>
7272
</dependency>
73+
<dependency>
74+
<groupId>org.mockito</groupId>
75+
<artifactId>mockito-core</artifactId>
76+
<scope>test</scope>
77+
</dependency>
7378
</dependencies>
7479
</project>

core/retries/src/main/java/software/amazon/awssdk/retries/internal/DefaultAdaptiveRetryStrategy.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,12 @@ public final class DefaultAdaptiveRetryStrategy
4343

4444
@Override
4545
protected Duration computeInitialBackoff(AcquireInitialTokenRequest request) {
46-
RateLimiterTokenBucket bucket = rateLimiterTokenBucketStore.tokenBucketForScope(request.scope());
47-
return bucket.tryAcquire().delay();
46+
throw new UnsupportedOperationException("TODO");
4847
}
4948

5049
@Override
5150
protected Duration computeBackoff(RefreshRetryTokenRequest request, DefaultRetryToken token) {
52-
Duration backoff = super.computeBackoff(request, token);
53-
RateLimiterTokenBucket bucket = rateLimiterTokenBucketStore.tokenBucketForScope(token.scope());
54-
return backoff.plus(bucket.tryAcquire().delay());
51+
throw new UnsupportedOperationException("TODO");
5552
}
5653

5754
@Override

core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucket.java

Lines changed: 140 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,42 +16,127 @@
1616
package software.amazon.awssdk.retries.internal.ratelimiter;
1717

1818
import java.time.Duration;
19+
import java.util.ArrayDeque;
20+
import java.util.Deque;
21+
import java.util.concurrent.CompletableFuture;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.ScheduledExecutorService;
24+
import java.util.concurrent.TimeUnit;
1925
import java.util.concurrent.atomic.AtomicReference;
2026
import java.util.function.Consumer;
2127
import java.util.function.Function;
2228
import software.amazon.awssdk.annotations.SdkInternalApi;
29+
import software.amazon.awssdk.annotations.SdkTestInternalApi;
30+
import software.amazon.awssdk.annotations.ThreadSafe;
31+
import software.amazon.awssdk.utils.SdkAutoCloseable;
2332

2433
/**
2534
* The {@link RateLimiterTokenBucket} keeps track of past throttling responses and adapts to slow down the send rate to adapt to
26-
* the service. It does this by suggesting a delay amount as result of a {@link #tryAcquire()} call. Callers must update its
27-
* internal state by calling {@link #updateRateAfterThrottling()} when getting a throttling response or
28-
* {@link #updateRateAfterSuccess()} when getting successful response.
35+
* the service. It does this by delaying the completion of the future returned by {@link #acquireAsync()} until the requested
36+
* capacity is available. Callers must update its internal state by calling {@link #updateRateAfterThrottling()} when getting a
37+
* throttling response or {@link #updateRateAfterSuccess()} when getting successful response.
2938
*
30-
* <p>This class is thread-safe, its internal current state is kept in the inner class {@link PersistentState} which is stored
31-
* using an {@link AtomicReference}. This class is converted to {@link TransientState} when the state needs to be mutated and
32-
* converted back to a {@link PersistentState} and stored using {@link AtomicReference#compareAndSet(Object, Object)}.
39+
* <p>This class is thread-safe.
3340
*
3441
* <p>The algorithm used is adapted from the network congestion avoidance algorithm
3542
* <a href="https://en.wikipedia.org/wiki/CUBIC_TCP">CUBIC</a>.
3643
*/
3744
@SdkInternalApi
38-
public class RateLimiterTokenBucket {
45+
@ThreadSafe
46+
public class RateLimiterTokenBucket implements SdkAutoCloseable {
47+
// Thread used for capacity waiting and notifying.
48+
private final ScheduledExecutorService scheduler;
49+
// the collection of futures returned to threads currently waiting for capacity. Futures are completed in FIFO order.
50+
private final Deque<CompletableFuture<Void>> waiting = new ArrayDeque<>();
3951
private final AtomicReference<PersistentState> stateReference;
4052
private final RateLimiterClock clock;
53+
private final Object lock = new Object();
4154

42-
RateLimiterTokenBucket(RateLimiterClock clock) {
55+
private boolean notifierRunning = false;
56+
57+
RateLimiterTokenBucket(RateLimiterClock clock, ScheduledExecutorService scheduler) {
4358
this.clock = clock;
59+
this.scheduler = scheduler;
4460
this.stateReference = new AtomicReference<>(new PersistentState());
4561
}
4662

63+
@Override
64+
public void close() {
65+
scheduler.shutdownNow();
66+
}
67+
4768
/**
48-
* Acquire tokens from the bucket. If the bucket contains enough capacity to satisfy the request, this method will return in
49-
* {@link RateLimiterAcquireResponse#delay()} a {@link Duration#ZERO} value, otherwise it will return the amount of time the
50-
* callers need to wait until enough tokens are refilled.
69+
* Acquire a token from the bucket.
70+
*
71+
* @return A future that is completed when the requested amount is acquired from this bucket.
5172
*/
52-
public RateLimiterAcquireResponse tryAcquire() {
53-
StateUpdate<Duration> update = updateState(ts -> ts.tokenBucketAcquire(clock, 1.0));
54-
return RateLimiterAcquireResponse.create(update.result);
73+
public CompletableFuture<Void> acquireAsync() {
74+
synchronized (lock) {
75+
CompletableFuture<Void> future = new CompletableFuture<>();
76+
if (!notifierRunning) {
77+
waiting.add(future);
78+
boolean scheduled = scheduleOrFail(this::doNotify, Duration.ZERO, future);
79+
if (scheduled) {
80+
notifierRunning = true;
81+
} else {
82+
waiting.pop();
83+
}
84+
}
85+
return future;
86+
}
87+
}
88+
89+
public void doNotify() {
90+
synchronized (lock) {
91+
while (true) {
92+
CompletableFuture<Void> w = waiting.poll();
93+
if (w == null) {
94+
notifierRunning = false;
95+
return;
96+
}
97+
98+
PersistentState persistentState = stateReference.get();
99+
TransientState ts = persistentState.toTransient();
100+
TransientState.AcquireResult acquireResult = ts.tokenBucketAcquire(clock, 1.0);
101+
stateReference.set(ts.toPersistent());
102+
103+
// Not enough capacity. Try again later when enough time has passed to refill the bucket at the current rate.
104+
if (!acquireResult.isSuccessful()) {
105+
waiting.push(w);
106+
107+
if (!scheduleOrFail(this::doNotify, acquireResult.refillWait(), w)) {
108+
waiting.pop();
109+
return;
110+
}
111+
}
112+
113+
// Acquire was successful, signal the waiting thread.
114+
w.complete(null);
115+
}
116+
}
117+
}
118+
119+
@SdkTestInternalApi
120+
Deque<CompletableFuture<Void>> waiting() {
121+
return waiting;
122+
}
123+
124+
private void schedule(Runnable command, Duration d) {
125+
scheduler.schedule(command, d.toMillis(), TimeUnit.MILLISECONDS);
126+
}
127+
128+
/**
129+
* @return true if schedule was successful, false otherwise.
130+
*/
131+
private boolean scheduleOrFail(Runnable command, Duration d, CompletableFuture<?> future) {
132+
try {
133+
schedule(command, d);
134+
return true;
135+
} catch (Throwable t) {
136+
RuntimeException e = new RuntimeException("Unable to initiate token acquire", t);
137+
future.completeExceptionally(e);
138+
}
139+
return false;
55140
}
56141

57142
/**
@@ -95,17 +180,20 @@ private StateUpdate<Void> consumeState(Consumer<TransientState> mutator) {
95180
* retried until succeeded.
96181
*/
97182
private <T> StateUpdate<T> updateState(Function<TransientState, T> mutator) {
98-
PersistentState current;
99-
PersistentState updated;
100-
T result;
101-
do {
102-
current = stateReference.get();
103-
TransientState transientState = current.toTransient();
104-
result = mutator.apply(transientState);
105-
updated = transientState.toPersistent();
106-
} while (!stateReference.compareAndSet(current, updated));
107-
108-
return new StateUpdate<>(updated, result);
183+
synchronized (lock) {
184+
PersistentState current;
185+
PersistentState updated;
186+
T result;
187+
do {
188+
current = stateReference.get();
189+
TransientState transientState = current.toTransient();
190+
result = mutator.apply(transientState);
191+
updated = transientState.toPersistent();
192+
} while (!stateReference.compareAndSet(current, updated));
193+
194+
195+
return new StateUpdate<>(updated, result);
196+
}
109197
}
110198

111199
static class StateUpdate<T> {
@@ -163,17 +251,39 @@ PersistentState toPersistent() {
163251
* a {@link Duration#ZERO} value, otherwise it will return the amount of time the callers need to wait until enough tokens
164252
* are refilled.
165253
*/
166-
Duration tokenBucketAcquire(RateLimiterClock clock, double amount) {
254+
AcquireResult tokenBucketAcquire(RateLimiterClock clock, double amount) {
167255
if (!this.enabled) {
168-
return Duration.ZERO;
256+
return new AcquireResult(true, Duration.ZERO);
169257
}
170258
refill(clock);
171-
double waitTime = 0.0;
172259
if (this.currentCapacity < amount) {
173-
waitTime = (amount - this.currentCapacity) / this.fillRate;
260+
double diff = amount - currentCapacity;
261+
this.currentCapacity = 0;
262+
double waitTime = diff / this.fillRate;
263+
double waitTimeMs = waitTime * 1_000.0;
264+
Duration duration = Duration.ofMillis((long) Math.ceil(waitTimeMs));
265+
return new AcquireResult(false, duration);
174266
}
175267
this.currentCapacity -= amount;
176-
return Duration.ofNanos((long) (waitTime * 1_000_000_000.0));
268+
return new AcquireResult(true, Duration.ZERO);
269+
}
270+
271+
private static class AcquireResult {
272+
final boolean successful;
273+
final Duration refillWait;
274+
275+
public AcquireResult(boolean successful, Duration refillWait) {
276+
this.successful = successful;
277+
this.refillWait = refillWait;
278+
}
279+
280+
public boolean isSuccessful() {
281+
return successful;
282+
}
283+
284+
public Duration refillWait() {
285+
return refillWait;
286+
}
177287
}
178288

179289
/**

core/retries/src/main/java/software/amazon/awssdk/retries/internal/ratelimiter/RateLimiterTokenBucketStore.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
package software.amazon.awssdk.retries.internal.ratelimiter;
1717

18+
import java.util.concurrent.ScheduledExecutorService;
1819
import software.amazon.awssdk.annotations.SdkInternalApi;
1920
import software.amazon.awssdk.annotations.ToBuilderIgnoreField;
21+
import software.amazon.awssdk.utils.SdkAutoCloseable;
2022
import software.amazon.awssdk.utils.Validate;
2123
import software.amazon.awssdk.utils.builder.CopyableBuilder;
2224
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
@@ -27,19 +29,27 @@
2729
*/
2830
@SdkInternalApi
2931
public final class RateLimiterTokenBucketStore
30-
implements ToCopyableBuilder<RateLimiterTokenBucketStore.Builder, RateLimiterTokenBucketStore> {
32+
implements ToCopyableBuilder<RateLimiterTokenBucketStore.Builder, RateLimiterTokenBucketStore>, SdkAutoCloseable {
3133
private static final int MAX_ENTRIES = 128;
3234
private static final RateLimiterClock DEFAULT_CLOCK = new SystemClock();
3335
private final LruCache<String, RateLimiterTokenBucket> scopeToTokenBucket;
3436
private final RateLimiterClock clock;
37+
private final ScheduledExecutorService scheduler;
3538

3639
private RateLimiterTokenBucketStore(Builder builder) {
3740
this.clock = Validate.paramNotNull(builder.clock, "clock");
38-
this.scopeToTokenBucket = LruCache.<String, RateLimiterTokenBucket>builder(x -> new RateLimiterTokenBucket(clock))
41+
this.scheduler = Validate.paramNotNull(builder.scheduler, "scheduler");
42+
this.scopeToTokenBucket = LruCache.<String, RateLimiterTokenBucket>builder(
43+
x -> new RateLimiterTokenBucket(clock, scheduler))
3944
.maxSize(MAX_ENTRIES)
4045
.build();
4146
}
4247

48+
@Override
49+
public void close() {
50+
scheduler.shutdownNow();
51+
}
52+
4353
public RateLimiterTokenBucket tokenBucketForScope(String scope) {
4454
return scopeToTokenBucket.get(scope);
4555
}
@@ -56,6 +66,7 @@ public static RateLimiterTokenBucketStore.Builder builder() {
5666

5767
public static class Builder implements CopyableBuilder<Builder, RateLimiterTokenBucketStore> {
5868
private RateLimiterClock clock;
69+
private ScheduledExecutorService scheduler;
5970

6071
Builder() {
6172
this.clock = DEFAULT_CLOCK;
@@ -70,6 +81,11 @@ public Builder clock(RateLimiterClock clock) {
7081
return this;
7182
}
7283

84+
public Builder scheduler(ScheduledExecutorService scheduler) {
85+
this.scheduler = scheduler;
86+
return this;
87+
}
88+
7389
@Override
7490
public RateLimiterTokenBucketStore build() {
7591
return new RateLimiterTokenBucketStore(this);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.retries.internal.ratelimiter;
17+
18+
import static org.mockito.Mockito.mock;
19+
import static org.mockito.Mockito.verify;
20+
21+
import java.util.concurrent.ScheduledExecutorService;
22+
import org.junit.jupiter.api.Test;
23+
24+
public class RateLimiterTokenBucketStoreTest {
25+
@Test
26+
void close_closesScheduler() {
27+
ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class);
28+
RateLimiterTokenBucketStore store = RateLimiterTokenBucketStore.builder()
29+
.clock(new SystemClock())
30+
.scheduler(mockScheduler)
31+
.build();
32+
33+
store.close();
34+
35+
verify(mockScheduler).shutdownNow();
36+
}
37+
}

0 commit comments

Comments
 (0)