Skip to content

Commit 2fe64a7

Browse files
committed
feat: first attempt at retryableOperation
1 parent aa47852 commit 2fe64a7

6 files changed

Lines changed: 816 additions & 0 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+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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.retry.RetryDecision;
11+
12+
/**
13+
* Replay-safe retry loop for any durable operation.
14+
*
15+
* <p>Provides the same retry-with-backoff pattern that {@code context.step()} has built in, but for operations that
16+
* cannot live inside a step ({@code waitForCallback}, {@code invoke}, {@code waitForCondition}, etc.).
17+
*
18+
* <p>Every side-effect in the loop is a durable operation, so the loop is replay-safe by construction. On replay,
19+
* completed operations return cached results instantly and the loop fast-forwards to the current attempt.
20+
*
21+
* <h2>Usage — callback retry</h2>
22+
*
23+
* <pre>{@code
24+
* var result = RetryOperationHelper.retryOperation(
25+
* context,
26+
* "approval",
27+
* (ctx, attempt) -> ctx.waitForCallback(
28+
* "approval-" + attempt,
29+
* String.class,
30+
* (callbackId, stepCtx) -> sendApprovalEmail(approverEmail, callbackId)
31+
* ),
32+
* RetryOperationConfig.builder()
33+
* .retryStrategy(RetryStrategies.exponentialBackoff(
34+
* 3, Duration.ofSeconds(2), Duration.ofSeconds(30), 2.0, JitterStrategy.FULL))
35+
* .build()
36+
* );
37+
* }</pre>
38+
*
39+
* <h2>Usage — invoke retry (anonymous form)</h2>
40+
*
41+
* <pre>{@code
42+
* var result = RetryOperationHelper.retryOperation(
43+
* context,
44+
* (ctx, attempt) -> ctx.invoke(
45+
* "charge-" + attempt, paymentFnArn, new ChargeRequest(orderId), String.class),
46+
* RetryOperationConfig.builder()
47+
* .retryStrategy((err, att) -> att < 3
48+
* ? RetryDecision.retry(Duration.ofSeconds(1))
49+
* : RetryDecision.fail())
50+
* .build()
51+
* );
52+
* }</pre>
53+
*/
54+
public final class RetryOperationHelper {
55+
56+
private static final Duration DEFAULT_BACKOFF_DELAY = Duration.ofSeconds(1);
57+
private static final String BACKOFF_SUFFIX = "-backoff-";
58+
private static final String ANONYMOUS_BACKOFF_PREFIX = "retry-backoff-";
59+
60+
private RetryOperationHelper() {
61+
// utility class
62+
}
63+
64+
/**
65+
* Named form — wraps the retry loop in {@code runInChildContext} by default so all attempts are grouped under a
66+
* single named operation in execution history.
67+
*
68+
* <p>The child-context wrapping can be disabled via
69+
* {@link RetryOperationConfig.Builder#wrapInChildContext(boolean)}.
70+
*
71+
* @param <T> the result type
72+
* @param context the durable context
73+
* @param name operation name (used for child context and backoff wait names)
74+
* @param operation the retryable operation — receives the context and 1-based attempt number
75+
* @param config retry configuration including the retry strategy
76+
* @return the operation result
77+
*/
78+
@SuppressWarnings("unchecked")
79+
public static <T> T retryOperation(
80+
DurableContext context, String name, RetryableOperation<T> operation, RetryOperationConfig config) {
81+
Objects.requireNonNull(context, "context cannot be null");
82+
Objects.requireNonNull(name, "name cannot be null");
83+
Objects.requireNonNull(operation, "operation cannot be null");
84+
Objects.requireNonNull(config, "config cannot be null");
85+
86+
if (config.wrapInChildContext()) {
87+
return (T) context.runInChildContext(
88+
name, new TypeToken<Object>() {}, childCtx -> executeRetryLoop(childCtx, name, operation, config));
89+
}
90+
return executeRetryLoop(context, name, operation, config);
91+
}
92+
93+
/**
94+
* Anonymous form — runs the retry loop directly in the caller's context. No child-context wrapping is applied
95+
* regardless of the {@code wrapInChildContext} config setting.
96+
*
97+
* @param <T> the result type
98+
* @param context the durable context
99+
* @param operation the retryable operation — receives the context and 1-based attempt number
100+
* @param config retry configuration including the retry strategy
101+
* @return the operation result
102+
*/
103+
public static <T> T retryOperation(
104+
DurableContext context, RetryableOperation<T> operation, RetryOperationConfig config) {
105+
Objects.requireNonNull(context, "context cannot be null");
106+
Objects.requireNonNull(operation, "operation cannot be null");
107+
Objects.requireNonNull(config, "config cannot be null");
108+
109+
return executeRetryLoop(context, null, operation, config);
110+
}
111+
112+
/**
113+
* Core retry loop. Replay-safe because every side-effect is a durable operation: the user's operation calls durable
114+
* primitives, and backoff uses {@code context.wait()}.
115+
*/
116+
private static <T> T executeRetryLoop(
117+
DurableContext context, String name, RetryableOperation<T> operation, RetryOperationConfig config) {
118+
var attempt = 1;
119+
while (true) {
120+
try {
121+
return operation.execute(context, attempt);
122+
} catch (Exception e) {
123+
RetryDecision decision = config.retryStrategy().makeRetryDecision(e, attempt);
124+
if (!decision.shouldRetry()) {
125+
throw e;
126+
}
127+
128+
var delay = decision.delay().isZero() ? DEFAULT_BACKOFF_DELAY : decision.delay();
129+
var waitName = name != null ? name + BACKOFF_SUFFIX + attempt : ANONYMOUS_BACKOFF_PREFIX + attempt;
130+
context.wait(waitName, delay);
131+
attempt++;
132+
}
133+
}
134+
}
135+
}
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)