Skip to content

Commit

Permalink
Add initial implementation of synchronous Waiters (#571)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hpmellema authored Feb 3, 2025
1 parent 0ebe210 commit ce216dc
Show file tree
Hide file tree
Showing 49 changed files with 1,904 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ dependencies {

// Avoid circular dependency in codegen core
if (project.name != "core") {
implementation(project(":codegen:core"))
api(project(":codegen:core"))
}
}

Expand Down
15 changes: 15 additions & 0 deletions client/waiters/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id("smithy-java.module-conventions")
}

description = "This module provides the Smithy Java Waiter implementation"

extra["displayName"] = "Smithy :: Java :: Waiters"
extra["moduleName"] = "software.amazon.smithy.java.waiters"

dependencies {
api(libs.smithy.waiters)
implementation(project(":jmespath"))
implementation(project(":logging"))
implementation(project(":client:client-core"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.waiters;

import java.util.Objects;
import software.amazon.smithy.java.core.schema.SerializableStruct;
import software.amazon.smithy.java.waiters.matching.Matcher;

/**
* Causes a waiter to transition states if the polling function input/output match a condition.
*
* @param <I> Input type of polling function.
* @param <O> Output type of polling function.
*/
record Acceptor<I extends SerializableStruct, O extends SerializableStruct>(
WaiterState state,
Matcher<I, O> matcher) {
Acceptor {
Objects.requireNonNull(matcher, "matcher cannot be null");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.waiters;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import software.amazon.smithy.java.client.core.RequestOverrideConfig;
import software.amazon.smithy.java.core.error.ModeledException;
import software.amazon.smithy.java.core.schema.SerializableStruct;
import software.amazon.smithy.java.waiters.backoff.BackoffStrategy;
import software.amazon.smithy.java.waiters.matching.Matcher;

/**
* Waiters are used to poll a resource until a desired state is reached, or until it is determined that the resource
* has reached an undesirable terminal state.
*
* <p>Waiters will repeatedly poll for the state of a resource using a provided polling function. The state of the
* resource is then evaluated using a number of {@code Acceptor}s. These acceptors are evaluated in a fixed order and
* can transition the state of the waiter if they determine the resource state matches some condition.
*
* <p>{@code SUCCESS} and {@code FAILURE} states are terminal states for Waiters and will cause the waiter to complete, returning the
* terminal status. The default waiter state {@code RETRY} causes the waiter to retry polling the resource state. Retries are
* performed using an exponential backoff approach with jitter.
*
* <p>Example usage<pre>{@code
* var waiter = Waiter.builder(client::getFoo)
* .success(Matcher.output(o -> o.status().equals("DONE")))
* .failure(Matcher.output(o -> o.status().equals("STOPPED")))
* .build();
* waiter.wait(GetFooInput.builder().id("my-id").build(), 1000);
* }</pre>
*
* @param <I> Input type of resource polling function.
* @param <O> Output type of resource polling function.
* @see <a href="https://smithy.io/2.0/additional-specs/waiters.html">Waiter Specification</a>
*/
public final class Waiter<I extends SerializableStruct, O extends SerializableStruct> implements WaiterSettings {
private final Waitable<I, O> pollingFunction;
private final List<Acceptor<I, O>> acceptors;
private BackoffStrategy backoffStrategy;
private RequestOverrideConfig overrideConfig;

private Waiter(Builder<I, O> builder) {
this.pollingFunction = builder.pollingFunction;
this.acceptors = Collections.unmodifiableList(builder.acceptors);
this.backoffStrategy = Objects.requireNonNullElse(builder.backoffStrategy, BackoffStrategy.getDefault());
}

/**
* Wait for the resource to reach a terminal state.
*
* @param input Input to use for polling function.
* @param maxWaitTime maximum amount of time for waiter to wait.
* @throws WaiterFailureException if the waiter reaches a FAILURE state
*/
public void wait(I input, Duration maxWaitTime) {
wait(input, maxWaitTime.toMillis());
}

/**
* Wait for the resource to reach a terminal state.
*
* @param input Input to use for polling function.
* @param maxWaitTimeMillis maximum wait time
* @throws WaiterFailureException if the waiter reaches a FAILURE state
*/
public void wait(I input, long maxWaitTimeMillis) {
int attemptNumber = 0;
long startTime = System.currentTimeMillis();

while (true) {
attemptNumber++;

ModeledException exception = null;
O output = null;
// Execute call to get input and output types
try {
output = pollingFunction.poll(input, overrideConfig);
} catch (ModeledException modeledException) {
exception = modeledException;
} catch (Exception exc) {
throw WaiterFailureException.builder()
.message("Waiter encountered unexpected, unmodeled exception while polling.")
.attemptNumber(attemptNumber)
.cause(exc)
.totalTimeMillis(System.currentTimeMillis() - startTime)
.build();
}

WaiterState state;
try {
state = resolveState(input, output, exception);
} catch (Exception exc) {
throw WaiterFailureException.builder()
.message("Waiter encountered unexpected exception.")
.cause(exc)
.attemptNumber(attemptNumber)
.totalTimeMillis(System.currentTimeMillis() - startTime)
.build();
}

switch (state) {
case SUCCESS:
return;
case RETRY:
waitToRetry(attemptNumber, maxWaitTimeMillis, startTime);
break;
case FAILURE:
throw WaiterFailureException.builder()
.message("Waiter reached terminal, FAILURE state")
.attemptNumber(attemptNumber)
.totalTimeMillis(System.currentTimeMillis() - startTime)
.build();
}
}
}

private WaiterState resolveState(I input, O output, ModeledException exception) {
// Update state based on first matcher that matches
for (Acceptor<I, O> acceptor : acceptors) {
if (acceptor.matcher().matches(input, output, exception)) {
return acceptor.state();
}
}

// If there was an unmatched exception return failure
if (exception != null) {
throw exception;
}

// Otherwise retry
return WaiterState.RETRY;
}

private void waitToRetry(int attemptNumber, long maxWaitTimeMillis, long startTimeMillis) {
long elapsedTimeMillis = System.currentTimeMillis() - startTimeMillis;
long remainingTime = maxWaitTimeMillis - elapsedTimeMillis;

if (remainingTime < 0) {
throw WaiterFailureException.builder()
.message("Waiter timed out after " + attemptNumber + " retry attempts.")
.attemptNumber(attemptNumber)
.totalTimeMillis(elapsedTimeMillis)
.build();
}
var delay = backoffStrategy.computeNextDelayInMills(attemptNumber, remainingTime);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw WaiterFailureException.builder()
.message("Waiter interrupted while waiting to retry.")
.attemptNumber(attemptNumber)
.totalTimeMillis(System.currentTimeMillis() - startTimeMillis)
.build();
}
}

@Override
public void backoffStrategy(BackoffStrategy backoffStrategy) {
this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null.");
}

@Override
public void overrideConfig(RequestOverrideConfig overrideConfig) {
this.overrideConfig = Objects.requireNonNull(overrideConfig, "overrideConfig cannot be null.");
}

/**
* Create a new {@link Builder}.
*
* @param pollingFunction Client call that will be used to poll for the resource state.
* @return new {@link Builder} instance.
* @param <I> Input shape type
* @param <O> Output shape type
*/
public static <I extends SerializableStruct,
O extends SerializableStruct> Builder<I, O> builder(Waitable<I, O> pollingFunction) {
return new Builder<>(pollingFunction);
}

/**
* Static builder for {@link Waiter}.
*
* @param <I> Polling function input shape type
* @param <O> Polling function output shape type
*/
public static final class Builder<I extends SerializableStruct, O extends SerializableStruct> {
private final List<Acceptor<I, O>> acceptors = new ArrayList<>();
private final Waitable<I, O> pollingFunction;
private BackoffStrategy backoffStrategy;

private Builder(Waitable<I, O> pollingFunction) {
this.pollingFunction = pollingFunction;
}

/**
* Add a matcher to the Waiter that will transition the waiter to a SUCCESS state if matched.
*
* @param matcher matcher to add
* @return this builder
*/
public Builder<I, O> success(Matcher<I, O> matcher) {
this.acceptors.add(new Acceptor<>(WaiterState.SUCCESS, matcher));
return this;
}

/**
* Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched.
*
* @param matcher matcher to add
* @return this builder
*/
public Builder<I, O> failure(Matcher<I, O> matcher) {
this.acceptors.add(new Acceptor<>(WaiterState.FAILURE, matcher));
return this;
}

/**
* Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched.
*
* @param matcher acceptor to add
* @return this builder
*/
public Builder<I, O> retry(Matcher<I, O> matcher) {
this.acceptors.add(new Acceptor<>(WaiterState.RETRY, matcher));
return this;
}

/**
* Backoff strategy to use when polling for resource state.
*
* @param backoffStrategy backoff strategy to use
* @return this builder
*/
public Builder<I, O> backoffStrategy(BackoffStrategy backoffStrategy) {
this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null");
return this;
}

/**
* Create an immutable {@link Waiter} instance.
*
* @return the built {@code Waiter} object.
*/
public Waiter<I, O> build() {
return new Waiter<>(this);
}
}

/**
* Interface representing a function that can be polled for the state of a resource.
*/
@FunctionalInterface
public interface Waitable<I extends SerializableStruct, O extends SerializableStruct> {
O poll(I input, RequestOverrideConfig requestContext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.waiters;

import java.time.Duration;
import java.util.Objects;

/**
* Indicates that a {@link Waiter} reached a terminal, FAILURE state.
*
* <p>{@code Waiter}'s can reach a terminal FAILURE state if:
* <ul>
* <li>A matching FAILURE acceptor transitions the {@code Waiter} to a FAILURE state.</li>
* <li>The {@code Waiter} times out.</li>
* <li>The {@code Waiter} encounters an unknown exception.</li>
* </ul>
*/
public final class WaiterFailureException extends RuntimeException {
private final int attemptNumber;
private final long totalTimeMillis;

private WaiterFailureException(Builder builder) {
super(Objects.requireNonNull(builder.message, "message cannot be null."), builder.cause);
this.attemptNumber = builder.attemptNumber;
this.totalTimeMillis = builder.totalTimeMillis;
}

public int getAttemptNumber() {
return attemptNumber;
}

public Duration getTotalTime() {
return Duration.ofMillis(totalTimeMillis);
}

/**
* @return new static builder for {@link WaiterFailureException}.
*/
public static Builder builder() {
return new Builder();
}

/**
* Static builder for {@link WaiterFailureException}.
*/
public static final class Builder {
private Throwable cause;
private String message;
private int attemptNumber;
private long totalTimeMillis;

private Builder() {}

public Builder message(String message) {
this.message = message;
return this;
}

public Builder cause(Throwable cause) {
this.cause = cause;
return this;
}

public Builder attemptNumber(int attemptNumber) {
this.attemptNumber = attemptNumber;
return this;
}

public Builder totalTimeMillis(long totalTimeMillis) {
this.totalTimeMillis = totalTimeMillis;
return this;
}

public WaiterFailureException build() {
return new WaiterFailureException(this);
}
}
}
Loading

0 comments on commit ce216dc

Please sign in to comment.