Skip to content

Commit ce216dc

Browse files
authored
Add initial implementation of synchronous Waiters (#571)
Adds initial implementation of Synchronous waiters. Waiters are a client-side abstraction used to poll a resource until a desired state is reached, or until it is determined that the resource will never enter into the desired state. Waiters can be created without codegen, but a codegen integration is provided to automatically create waiters base on trait in the service model.
1 parent 0ebe210 commit ce216dc

File tree

49 files changed

+1904
-9
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1904
-9
lines changed

buildSrc/src/main/kotlin/smithy-java.codegen-plugin-conventions.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ dependencies {
1717

1818
// Avoid circular dependency in codegen core
1919
if (project.name != "core") {
20-
implementation(project(":codegen:core"))
20+
api(project(":codegen:core"))
2121
}
2222
}
2323

client/waiters/build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
plugins {
2+
id("smithy-java.module-conventions")
3+
}
4+
5+
description = "This module provides the Smithy Java Waiter implementation"
6+
7+
extra["displayName"] = "Smithy :: Java :: Waiters"
8+
extra["moduleName"] = "software.amazon.smithy.java.waiters"
9+
10+
dependencies {
11+
api(libs.smithy.waiters)
12+
implementation(project(":jmespath"))
13+
implementation(project(":logging"))
14+
implementation(project(":client:client-core"))
15+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.waiters;
7+
8+
import java.util.Objects;
9+
import software.amazon.smithy.java.core.schema.SerializableStruct;
10+
import software.amazon.smithy.java.waiters.matching.Matcher;
11+
12+
/**
13+
* Causes a waiter to transition states if the polling function input/output match a condition.
14+
*
15+
* @param <I> Input type of polling function.
16+
* @param <O> Output type of polling function.
17+
*/
18+
record Acceptor<I extends SerializableStruct, O extends SerializableStruct>(
19+
WaiterState state,
20+
Matcher<I, O> matcher) {
21+
Acceptor {
22+
Objects.requireNonNull(matcher, "matcher cannot be null");
23+
}
24+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.waiters;
7+
8+
import java.time.Duration;
9+
import java.util.ArrayList;
10+
import java.util.Collections;
11+
import java.util.List;
12+
import java.util.Objects;
13+
import software.amazon.smithy.java.client.core.RequestOverrideConfig;
14+
import software.amazon.smithy.java.core.error.ModeledException;
15+
import software.amazon.smithy.java.core.schema.SerializableStruct;
16+
import software.amazon.smithy.java.waiters.backoff.BackoffStrategy;
17+
import software.amazon.smithy.java.waiters.matching.Matcher;
18+
19+
/**
20+
* Waiters are used to poll a resource until a desired state is reached, or until it is determined that the resource
21+
* has reached an undesirable terminal state.
22+
*
23+
* <p>Waiters will repeatedly poll for the state of a resource using a provided polling function. The state of the
24+
* resource is then evaluated using a number of {@code Acceptor}s. These acceptors are evaluated in a fixed order and
25+
* can transition the state of the waiter if they determine the resource state matches some condition.
26+
*
27+
* <p>{@code SUCCESS} and {@code FAILURE} states are terminal states for Waiters and will cause the waiter to complete, returning the
28+
* terminal status. The default waiter state {@code RETRY} causes the waiter to retry polling the resource state. Retries are
29+
* performed using an exponential backoff approach with jitter.
30+
*
31+
* <p>Example usage<pre>{@code
32+
* var waiter = Waiter.builder(client::getFoo)
33+
* .success(Matcher.output(o -> o.status().equals("DONE")))
34+
* .failure(Matcher.output(o -> o.status().equals("STOPPED")))
35+
* .build();
36+
* waiter.wait(GetFooInput.builder().id("my-id").build(), 1000);
37+
* }</pre>
38+
*
39+
* @param <I> Input type of resource polling function.
40+
* @param <O> Output type of resource polling function.
41+
* @see <a href="https://smithy.io/2.0/additional-specs/waiters.html">Waiter Specification</a>
42+
*/
43+
public final class Waiter<I extends SerializableStruct, O extends SerializableStruct> implements WaiterSettings {
44+
private final Waitable<I, O> pollingFunction;
45+
private final List<Acceptor<I, O>> acceptors;
46+
private BackoffStrategy backoffStrategy;
47+
private RequestOverrideConfig overrideConfig;
48+
49+
private Waiter(Builder<I, O> builder) {
50+
this.pollingFunction = builder.pollingFunction;
51+
this.acceptors = Collections.unmodifiableList(builder.acceptors);
52+
this.backoffStrategy = Objects.requireNonNullElse(builder.backoffStrategy, BackoffStrategy.getDefault());
53+
}
54+
55+
/**
56+
* Wait for the resource to reach a terminal state.
57+
*
58+
* @param input Input to use for polling function.
59+
* @param maxWaitTime maximum amount of time for waiter to wait.
60+
* @throws WaiterFailureException if the waiter reaches a FAILURE state
61+
*/
62+
public void wait(I input, Duration maxWaitTime) {
63+
wait(input, maxWaitTime.toMillis());
64+
}
65+
66+
/**
67+
* Wait for the resource to reach a terminal state.
68+
*
69+
* @param input Input to use for polling function.
70+
* @param maxWaitTimeMillis maximum wait time
71+
* @throws WaiterFailureException if the waiter reaches a FAILURE state
72+
*/
73+
public void wait(I input, long maxWaitTimeMillis) {
74+
int attemptNumber = 0;
75+
long startTime = System.currentTimeMillis();
76+
77+
while (true) {
78+
attemptNumber++;
79+
80+
ModeledException exception = null;
81+
O output = null;
82+
// Execute call to get input and output types
83+
try {
84+
output = pollingFunction.poll(input, overrideConfig);
85+
} catch (ModeledException modeledException) {
86+
exception = modeledException;
87+
} catch (Exception exc) {
88+
throw WaiterFailureException.builder()
89+
.message("Waiter encountered unexpected, unmodeled exception while polling.")
90+
.attemptNumber(attemptNumber)
91+
.cause(exc)
92+
.totalTimeMillis(System.currentTimeMillis() - startTime)
93+
.build();
94+
}
95+
96+
WaiterState state;
97+
try {
98+
state = resolveState(input, output, exception);
99+
} catch (Exception exc) {
100+
throw WaiterFailureException.builder()
101+
.message("Waiter encountered unexpected exception.")
102+
.cause(exc)
103+
.attemptNumber(attemptNumber)
104+
.totalTimeMillis(System.currentTimeMillis() - startTime)
105+
.build();
106+
}
107+
108+
switch (state) {
109+
case SUCCESS:
110+
return;
111+
case RETRY:
112+
waitToRetry(attemptNumber, maxWaitTimeMillis, startTime);
113+
break;
114+
case FAILURE:
115+
throw WaiterFailureException.builder()
116+
.message("Waiter reached terminal, FAILURE state")
117+
.attemptNumber(attemptNumber)
118+
.totalTimeMillis(System.currentTimeMillis() - startTime)
119+
.build();
120+
}
121+
}
122+
}
123+
124+
private WaiterState resolveState(I input, O output, ModeledException exception) {
125+
// Update state based on first matcher that matches
126+
for (Acceptor<I, O> acceptor : acceptors) {
127+
if (acceptor.matcher().matches(input, output, exception)) {
128+
return acceptor.state();
129+
}
130+
}
131+
132+
// If there was an unmatched exception return failure
133+
if (exception != null) {
134+
throw exception;
135+
}
136+
137+
// Otherwise retry
138+
return WaiterState.RETRY;
139+
}
140+
141+
private void waitToRetry(int attemptNumber, long maxWaitTimeMillis, long startTimeMillis) {
142+
long elapsedTimeMillis = System.currentTimeMillis() - startTimeMillis;
143+
long remainingTime = maxWaitTimeMillis - elapsedTimeMillis;
144+
145+
if (remainingTime < 0) {
146+
throw WaiterFailureException.builder()
147+
.message("Waiter timed out after " + attemptNumber + " retry attempts.")
148+
.attemptNumber(attemptNumber)
149+
.totalTimeMillis(elapsedTimeMillis)
150+
.build();
151+
}
152+
var delay = backoffStrategy.computeNextDelayInMills(attemptNumber, remainingTime);
153+
try {
154+
Thread.sleep(delay);
155+
} catch (InterruptedException e) {
156+
Thread.currentThread().interrupt();
157+
throw WaiterFailureException.builder()
158+
.message("Waiter interrupted while waiting to retry.")
159+
.attemptNumber(attemptNumber)
160+
.totalTimeMillis(System.currentTimeMillis() - startTimeMillis)
161+
.build();
162+
}
163+
}
164+
165+
@Override
166+
public void backoffStrategy(BackoffStrategy backoffStrategy) {
167+
this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null.");
168+
}
169+
170+
@Override
171+
public void overrideConfig(RequestOverrideConfig overrideConfig) {
172+
this.overrideConfig = Objects.requireNonNull(overrideConfig, "overrideConfig cannot be null.");
173+
}
174+
175+
/**
176+
* Create a new {@link Builder}.
177+
*
178+
* @param pollingFunction Client call that will be used to poll for the resource state.
179+
* @return new {@link Builder} instance.
180+
* @param <I> Input shape type
181+
* @param <O> Output shape type
182+
*/
183+
public static <I extends SerializableStruct,
184+
O extends SerializableStruct> Builder<I, O> builder(Waitable<I, O> pollingFunction) {
185+
return new Builder<>(pollingFunction);
186+
}
187+
188+
/**
189+
* Static builder for {@link Waiter}.
190+
*
191+
* @param <I> Polling function input shape type
192+
* @param <O> Polling function output shape type
193+
*/
194+
public static final class Builder<I extends SerializableStruct, O extends SerializableStruct> {
195+
private final List<Acceptor<I, O>> acceptors = new ArrayList<>();
196+
private final Waitable<I, O> pollingFunction;
197+
private BackoffStrategy backoffStrategy;
198+
199+
private Builder(Waitable<I, O> pollingFunction) {
200+
this.pollingFunction = pollingFunction;
201+
}
202+
203+
/**
204+
* Add a matcher to the Waiter that will transition the waiter to a SUCCESS state if matched.
205+
*
206+
* @param matcher matcher to add
207+
* @return this builder
208+
*/
209+
public Builder<I, O> success(Matcher<I, O> matcher) {
210+
this.acceptors.add(new Acceptor<>(WaiterState.SUCCESS, matcher));
211+
return this;
212+
}
213+
214+
/**
215+
* Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched.
216+
*
217+
* @param matcher matcher to add
218+
* @return this builder
219+
*/
220+
public Builder<I, O> failure(Matcher<I, O> matcher) {
221+
this.acceptors.add(new Acceptor<>(WaiterState.FAILURE, matcher));
222+
return this;
223+
}
224+
225+
/**
226+
* Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched.
227+
*
228+
* @param matcher acceptor to add
229+
* @return this builder
230+
*/
231+
public Builder<I, O> retry(Matcher<I, O> matcher) {
232+
this.acceptors.add(new Acceptor<>(WaiterState.RETRY, matcher));
233+
return this;
234+
}
235+
236+
/**
237+
* Backoff strategy to use when polling for resource state.
238+
*
239+
* @param backoffStrategy backoff strategy to use
240+
* @return this builder
241+
*/
242+
public Builder<I, O> backoffStrategy(BackoffStrategy backoffStrategy) {
243+
this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null");
244+
return this;
245+
}
246+
247+
/**
248+
* Create an immutable {@link Waiter} instance.
249+
*
250+
* @return the built {@code Waiter} object.
251+
*/
252+
public Waiter<I, O> build() {
253+
return new Waiter<>(this);
254+
}
255+
}
256+
257+
/**
258+
* Interface representing a function that can be polled for the state of a resource.
259+
*/
260+
@FunctionalInterface
261+
public interface Waitable<I extends SerializableStruct, O extends SerializableStruct> {
262+
O poll(I input, RequestOverrideConfig requestContext);
263+
}
264+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.waiters;
7+
8+
import java.time.Duration;
9+
import java.util.Objects;
10+
11+
/**
12+
* Indicates that a {@link Waiter} reached a terminal, FAILURE state.
13+
*
14+
* <p>{@code Waiter}'s can reach a terminal FAILURE state if:
15+
* <ul>
16+
* <li>A matching FAILURE acceptor transitions the {@code Waiter} to a FAILURE state.</li>
17+
* <li>The {@code Waiter} times out.</li>
18+
* <li>The {@code Waiter} encounters an unknown exception.</li>
19+
* </ul>
20+
*/
21+
public final class WaiterFailureException extends RuntimeException {
22+
private final int attemptNumber;
23+
private final long totalTimeMillis;
24+
25+
private WaiterFailureException(Builder builder) {
26+
super(Objects.requireNonNull(builder.message, "message cannot be null."), builder.cause);
27+
this.attemptNumber = builder.attemptNumber;
28+
this.totalTimeMillis = builder.totalTimeMillis;
29+
}
30+
31+
public int getAttemptNumber() {
32+
return attemptNumber;
33+
}
34+
35+
public Duration getTotalTime() {
36+
return Duration.ofMillis(totalTimeMillis);
37+
}
38+
39+
/**
40+
* @return new static builder for {@link WaiterFailureException}.
41+
*/
42+
public static Builder builder() {
43+
return new Builder();
44+
}
45+
46+
/**
47+
* Static builder for {@link WaiterFailureException}.
48+
*/
49+
public static final class Builder {
50+
private Throwable cause;
51+
private String message;
52+
private int attemptNumber;
53+
private long totalTimeMillis;
54+
55+
private Builder() {}
56+
57+
public Builder message(String message) {
58+
this.message = message;
59+
return this;
60+
}
61+
62+
public Builder cause(Throwable cause) {
63+
this.cause = cause;
64+
return this;
65+
}
66+
67+
public Builder attemptNumber(int attemptNumber) {
68+
this.attemptNumber = attemptNumber;
69+
return this;
70+
}
71+
72+
public Builder totalTimeMillis(long totalTimeMillis) {
73+
this.totalTimeMillis = totalTimeMillis;
74+
return this;
75+
}
76+
77+
public WaiterFailureException build() {
78+
return new WaiterFailureException(this);
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)