Skip to content

Commit 8357d38

Browse files
committed
Merge branch 'main' into retryable-operation
2 parents eae41bd + 254dab1 commit 8357d38

7 files changed

Lines changed: 866 additions & 25 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.config;
4+
5+
import software.amazon.lambda.durable.retry.RetryStrategy;
6+
7+
/**
8+
* Configuration for {@link software.amazon.lambda.durable.util.RetryOperationHelper#retryOperation}.
9+
*
10+
* <p>Uses the same {@link RetryStrategy} shape that developers already know from {@link StepConfig}, so there are zero
11+
* new retry concepts to learn.
12+
*/
13+
public class RetryOperationConfig {
14+
private final RetryStrategy retryStrategy;
15+
private final boolean wrapInChildContext;
16+
17+
private RetryOperationConfig(Builder builder) {
18+
this.retryStrategy = builder.retryStrategy;
19+
this.wrapInChildContext = builder.wrapInChildContext;
20+
}
21+
22+
/**
23+
* Returns the retry strategy. Same type as {@link StepConfig#retryStrategy()}.
24+
*
25+
* @return the retry strategy, never null
26+
*/
27+
public RetryStrategy retryStrategy() {
28+
return retryStrategy;
29+
}
30+
31+
/**
32+
* Whether to wrap the retry loop in {@code runInChildContext} so all attempts are grouped under a single named
33+
* operation in execution history. Only applies when a name is provided to the named form of {@code retryOperation}.
34+
* Defaults to {@code true}.
35+
*
36+
* @return true if child-context wrapping is enabled
37+
*/
38+
public boolean wrapInChildContext() {
39+
return wrapInChildContext;
40+
}
41+
42+
/**
43+
* Creates a new builder for {@code RetryOperationConfig}.
44+
*
45+
* @return a new builder instance
46+
*/
47+
public static Builder builder() {
48+
return new Builder();
49+
}
50+
51+
/** Builder for creating {@link RetryOperationConfig} instances. */
52+
public static class Builder {
53+
private RetryStrategy retryStrategy;
54+
private boolean wrapInChildContext = true;
55+
56+
private Builder() {}
57+
58+
/**
59+
* Sets the retry strategy. Required.
60+
*
61+
* <p>Reuses the exact same {@link RetryStrategy} interface from {@link StepConfig}. All existing factory
62+
* methods ({@link software.amazon.lambda.durable.retry.RetryStrategies#exponentialBackoff},
63+
* {@link software.amazon.lambda.durable.retry.RetryStrategies#fixedDelay}, presets, and custom lambdas) work
64+
* without modification.
65+
*
66+
* @param retryStrategy the retry strategy to use
67+
* @return this builder for method chaining
68+
*/
69+
public Builder retryStrategy(RetryStrategy retryStrategy) {
70+
this.retryStrategy = retryStrategy;
71+
return this;
72+
}
73+
74+
/**
75+
* Controls whether the retry loop is wrapped in a child context. Only meaningful for the named form of
76+
* {@code retryOperation}. Defaults to {@code true}.
77+
*
78+
* <p>When {@code true}, all attempts and backoff waits are grouped under a single named operation in execution
79+
* history, providing a cleaner view and isolated operation ID space. Set to {@code false} to flatten attempts
80+
* into the parent context.
81+
*
82+
* @param wrapInChildContext whether to wrap in a child context
83+
* @return this builder for method chaining
84+
*/
85+
public Builder wrapInChildContext(boolean wrapInChildContext) {
86+
this.wrapInChildContext = wrapInChildContext;
87+
return this;
88+
}
89+
90+
/**
91+
* Builds the {@link RetryOperationConfig} instance.
92+
*
93+
* @return a new config with the configured options
94+
* @throws IllegalArgumentException if retryStrategy is not set
95+
*/
96+
public RetryOperationConfig build() {
97+
if (retryStrategy == null) {
98+
throw new IllegalArgumentException("retryStrategy is required");
99+
}
100+
return new RetryOperationConfig(this);
101+
}
102+
}
103+
}

sdk/src/main/java/software/amazon/lambda/durable/util/CompletedDurableFuture.java

Lines changed: 0 additions & 25 deletions
This file was deleted.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.util;
4+
5+
import java.time.Duration;
6+
import java.util.Objects;
7+
import software.amazon.lambda.durable.DurableContext;
8+
import software.amazon.lambda.durable.TypeToken;
9+
import software.amazon.lambda.durable.config.RetryOperationConfig;
10+
import software.amazon.lambda.durable.exception.UnrecoverableDurableExecutionException;
11+
import software.amazon.lambda.durable.execution.SuspendExecutionException;
12+
import software.amazon.lambda.durable.retry.RetryDecision;
13+
14+
/**
15+
* Replay-safe retry loop for any durable operation.
16+
*
17+
* <p>Provides the same retry-with-backoff pattern that {@code context.step()} has built in, but for operations that
18+
* cannot live inside a step ({@code waitForCallback}, {@code invoke}, {@code waitForCondition}, etc.).
19+
*
20+
* <p>Every side-effect in the loop is a durable operation, so the loop is replay-safe by construction. On replay,
21+
* completed operations return cached results instantly and the loop fast-forwards to the current attempt.
22+
*
23+
* <h2>Usage — callback retry</h2>
24+
*
25+
* <pre>{@code
26+
* var result = RetryOperationHelper.retryOperation(
27+
* context,
28+
* "approval",
29+
* (ctx, attempt) -> ctx.waitForCallback(
30+
* "approval-" + attempt,
31+
* String.class,
32+
* (callbackId, stepCtx) -> sendApprovalEmail(approverEmail, callbackId)
33+
* ),
34+
* RetryOperationConfig.builder()
35+
* .retryStrategy(RetryStrategies.exponentialBackoff(
36+
* 3, Duration.ofSeconds(2), Duration.ofSeconds(30), 2.0, JitterStrategy.FULL))
37+
* .build()
38+
* );
39+
* }</pre>
40+
*
41+
* <h2>Usage — invoke retry (anonymous form)</h2>
42+
*
43+
* <pre>{@code
44+
* var result = RetryOperationHelper.retryOperation(
45+
* context,
46+
* (ctx, attempt) -> ctx.invoke(
47+
* "charge-" + attempt, paymentFnArn, new ChargeRequest(orderId), String.class),
48+
* RetryOperationConfig.builder()
49+
* .retryStrategy((err, att) -> att < 3
50+
* ? RetryDecision.retry(Duration.ofSeconds(1))
51+
* : RetryDecision.fail())
52+
* .build()
53+
* );
54+
* }</pre>
55+
*/
56+
public final class RetryOperationHelper {
57+
58+
private static final Duration DEFAULT_BACKOFF_DELAY = Duration.ofSeconds(1);
59+
private static final String BACKOFF_SUFFIX = "-backoff-";
60+
private static final String ANONYMOUS_BACKOFF_PREFIX = "retry-backoff-";
61+
62+
private RetryOperationHelper() {
63+
// utility class
64+
}
65+
66+
/**
67+
* Named form — wraps the retry loop in {@code runInChildContext} by default so all attempts are grouped under a
68+
* single named operation in execution history.
69+
*
70+
* <p>The child-context wrapping can be disabled via
71+
* {@link RetryOperationConfig.Builder#wrapInChildContext(boolean)}.
72+
*
73+
* @param <T> the result type
74+
* @param context the durable context
75+
* @param name operation name (used for child context and backoff wait names)
76+
* @param operation the retryable operation — receives the context and 1-based attempt number
77+
* @param config retry configuration including the retry strategy
78+
* @return the operation result
79+
*/
80+
@SuppressWarnings("unchecked")
81+
public static <T> T retryOperation(
82+
DurableContext context, String name, RetryableOperation<T> operation, RetryOperationConfig config) {
83+
Objects.requireNonNull(context, "context cannot be null");
84+
Objects.requireNonNull(name, "name cannot be null");
85+
Objects.requireNonNull(operation, "operation cannot be null");
86+
Objects.requireNonNull(config, "config cannot be null");
87+
88+
if (config.wrapInChildContext()) {
89+
return (T) context.runInChildContext(
90+
name, new TypeToken<Object>() {}, childCtx -> executeRetryLoop(childCtx, name, operation, config));
91+
}
92+
return executeRetryLoop(context, name, operation, config);
93+
}
94+
95+
/**
96+
* Anonymous form — runs the retry loop directly in the caller's context. No child-context wrapping is applied
97+
* regardless of the {@code wrapInChildContext} config setting.
98+
*
99+
* @param <T> the result type
100+
* @param context the durable context
101+
* @param operation the retryable operation — receives the context and 1-based attempt number
102+
* @param config retry configuration including the retry strategy
103+
* @return the operation result
104+
*/
105+
public static <T> T retryOperation(
106+
DurableContext context, RetryableOperation<T> operation, RetryOperationConfig config) {
107+
Objects.requireNonNull(context, "context cannot be null");
108+
Objects.requireNonNull(operation, "operation cannot be null");
109+
Objects.requireNonNull(config, "config cannot be null");
110+
111+
return executeRetryLoop(context, null, operation, config);
112+
}
113+
114+
/**
115+
* Core retry loop. Replay-safe because every side-effect is a durable operation: the user's operation calls durable
116+
* primitives, and backoff uses {@code context.wait()}.
117+
*
118+
* <p>{@link SuspendExecutionException} and {@link UnrecoverableDurableExecutionException} are never retried — they
119+
* are internal SDK control flow signals that must propagate immediately.
120+
*/
121+
private static <T> T executeRetryLoop(
122+
DurableContext context, String name, RetryableOperation<T> operation, RetryOperationConfig config) {
123+
var attempt = 1;
124+
while (true) {
125+
try {
126+
return operation.execute(context, attempt);
127+
} catch (SuspendExecutionException | UnrecoverableDurableExecutionException e) {
128+
// Internal SDK control flow — never retry, always propagate
129+
throw e;
130+
} catch (Exception e) {
131+
RetryDecision decision = config.retryStrategy().makeRetryDecision(e, attempt);
132+
if (!decision.shouldRetry()) {
133+
throw e;
134+
}
135+
136+
var delay = decision.delay().isZero() ? DEFAULT_BACKOFF_DELAY : decision.delay();
137+
var waitName = name != null ? name + BACKOFF_SUFFIX + attempt : ANONYMOUS_BACKOFF_PREFIX + attempt;
138+
context.wait(waitName, delay);
139+
attempt++;
140+
}
141+
}
142+
}
143+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.util;
4+
5+
import software.amazon.lambda.durable.DurableContext;
6+
7+
/**
8+
* A durable operation that can be retried end-to-end by {@link RetryOperationHelper}.
9+
*
10+
* <p>Receives the durable context and the 1-based attempt number so callers can generate unique operation names per
11+
* attempt (e.g., {@code "approval-" + attempt}).
12+
*
13+
* @param <T> the result type
14+
*/
15+
@FunctionalInterface
16+
public interface RetryableOperation<T> {
17+
18+
/**
19+
* Executes the durable operation.
20+
*
21+
* @param context the durable context to use for durable operations
22+
* @param attempt the current attempt number (1-based: first attempt is 1)
23+
* @return the operation result
24+
*/
25+
T execute(DurableContext context, int attempt);
26+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.config;
4+
5+
import static org.junit.jupiter.api.Assertions.*;
6+
7+
import org.junit.jupiter.api.Test;
8+
import software.amazon.lambda.durable.retry.RetryDecision;
9+
import software.amazon.lambda.durable.retry.RetryStrategies;
10+
11+
class RetryOperationConfigTest {
12+
13+
@Test
14+
void builderWithRetryStrategy() {
15+
var strategy = RetryStrategies.Presets.DEFAULT;
16+
17+
var config = RetryOperationConfig.builder().retryStrategy(strategy).build();
18+
19+
assertEquals(strategy, config.retryStrategy());
20+
}
21+
22+
@Test
23+
void builderWithoutRetryStrategy_shouldThrow() {
24+
var exception = assertThrows(IllegalArgumentException.class, () -> RetryOperationConfig.builder()
25+
.build());
26+
27+
assertEquals("retryStrategy is required", exception.getMessage());
28+
}
29+
30+
@Test
31+
void wrapInChildContext_defaultsToTrue() {
32+
var config = RetryOperationConfig.builder()
33+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
34+
.build();
35+
36+
assertTrue(config.wrapInChildContext());
37+
}
38+
39+
@Test
40+
void wrapInChildContext_canBeSetToFalse() {
41+
var config = RetryOperationConfig.builder()
42+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
43+
.wrapInChildContext(false)
44+
.build();
45+
46+
assertFalse(config.wrapInChildContext());
47+
}
48+
49+
@Test
50+
void wrapInChildContext_canBeSetToTrue() {
51+
var config = RetryOperationConfig.builder()
52+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
53+
.wrapInChildContext(true)
54+
.build();
55+
56+
assertTrue(config.wrapInChildContext());
57+
}
58+
59+
@Test
60+
void builderChaining() {
61+
var strategy = RetryStrategies.Presets.DEFAULT;
62+
63+
var config = RetryOperationConfig.builder()
64+
.retryStrategy(strategy)
65+
.wrapInChildContext(false)
66+
.build();
67+
68+
assertEquals(strategy, config.retryStrategy());
69+
assertFalse(config.wrapInChildContext());
70+
}
71+
72+
@Test
73+
void builderWithCustomLambdaRetryStrategy() {
74+
var config = RetryOperationConfig.builder()
75+
.retryStrategy((error, attempt) -> RetryDecision.fail())
76+
.build();
77+
78+
assertNotNull(config.retryStrategy());
79+
}
80+
}

0 commit comments

Comments
 (0)