Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,27 @@ New code can use context-free static operations from `software.amazon.lambda.dur
| `DurableWaitForCallbackOperation` | `waitForCallback`, `waitForCallbackAsync` |
| `DurableWaitForConditionOperation` | `waitForCondition`, `waitForConditionAsync` |
| `DurableWithRetryOperation` | `withRetry`, `withRetryAsync` |
| `DurableReplaySafeValueOperation` | `uuid`, `now`, `random` and asynchronous variants |

The existing `DurableContext` instance methods and callback signatures remain supported for backward compatibility.

### Replay-safe values

`DurableReplaySafeValueOperation` provides common nondeterministic values through extension-backed STEP operations:

```java
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.now;
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.random;
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.uuid;

var requestId = uuid("request-id");
var createdAt = now("created-at");
var sample = random("sample");
```

Each value is generated once and checkpointed. Replays return the stored `UUID`, `Instant`, or `double` without
regenerating it. The no-argument overloads use the operation names `uuid`, `now`, and `random`.

Each static operation owns its configuration type. For example:

```java
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.now;
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.random;
import static software.amazon.lambda.durable.operation.DurableReplaySafeValueOperation.uuid;

import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.lambda.model.OperationType;
import software.amazon.lambda.durable.model.ExecutionStatus;
import software.amazon.lambda.durable.operation.DurableWaitOperation;
import software.amazon.lambda.durable.testing.LocalDurableTestRunner;

class ReplaySafeValueIntegrationTest {
@Test
void generatedValuesAreCheckpointedAndReusedAfterReplay() {
var handlerExecutions = new AtomicInteger();
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
handlerExecutions.incrementAndGet();
var values = new ReplaySafeValues(uuid(), now(), random());
DurableWaitOperation.wait("force-replay", Duration.ofSeconds(1));
return values;
})
.withOutputType(ReplaySafeValues.class);

var result = runner.runUntilComplete("input");

assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
assertTrue(handlerExecutions.get() >= 2);
var values = result.getResult();
assertEquals(values.uuid(), result.getOperation("uuid").getStepResult(UUID.class));
assertEquals(values.now(), result.getOperation("now").getStepResult(Instant.class));
assertEquals(values.random(), result.getOperation("random").getStepResult(Double.class));
assertTrue(values.random() >= 0.0);
assertTrue(values.random() < 1.0);
assertStep(
result.getOperation("uuid").getType(),
result.getOperation("uuid").getSubtype(),
"UUID");
assertStep(
result.getOperation("now").getType(), result.getOperation("now").getSubtype(), "Now");
assertStep(
result.getOperation("random").getType(),
result.getOperation("random").getSubtype(),
"Random");
}

private static void assertStep(OperationType type, String actualSubtype, String expectedSubtype) {
assertEquals(OperationType.STEP, type);
assertEquals(expectedSubtype, actualSubtype);
}

record ReplaySafeValues(UUID uuid, Instant now, double random) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.operation;

import static software.amazon.lambda.durable.model.OperationSubType.STEP;

import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import software.amazon.lambda.durable.DurableFuture;
import software.amazon.lambda.durable.TypeToken;
import software.amazon.lambda.durable.extension.ExtensionContext;
import software.amazon.lambda.durable.extension.ExtensionStepResult;
import software.amazon.lambda.durable.util.ParameterValidator;

/**
* Replay-safe helpers for common nondeterministic values.
*
* <p>Each helper checkpoints its generated value as a durable STEP. Replays return the checkpointed value without
* generating a new UUID, timestamp, or random number.
*/
public final class DurableReplaySafeValueOperation {
private static final String UUID_NAME = "uuid";
private static final String UUID_SUBTYPE = "UUID";
private static final String NOW_NAME = "now";
private static final String NOW_SUBTYPE = "Now";
private static final String RANDOM_NAME = "random";
private static final String RANDOM_SUBTYPE = "Random";

private DurableReplaySafeValueOperation() {}

/** Returns a checkpointed random UUID using the default operation name {@code uuid}. */
public static UUID uuid() {
return uuid(UUID_NAME);
}

/** Returns a checkpointed random UUID. */
public static UUID uuid(String name) {
return uuidAsync(name).get();
}

/** Returns a future for a checkpointed random UUID using the default operation name {@code uuid}. */
public static DurableFuture<UUID> uuidAsync() {
return uuidAsync(UUID_NAME);
}

/** Returns a future for a checkpointed random UUID. */
public static DurableFuture<UUID> uuidAsync(String name) {
return checkpointedValueAsync(name, UUID_SUBTYPE, UUID.class, UUID::randomUUID);
}

/** Returns a checkpointed current instant using the default operation name {@code now}. */
public static Instant now() {
return now(NOW_NAME);
}

/** Returns a checkpointed current instant. */
public static Instant now(String name) {
return nowAsync(name).get();
}

/** Returns a future for a checkpointed current instant using the default operation name {@code now}. */
public static DurableFuture<Instant> nowAsync() {
return nowAsync(NOW_NAME);
}

/** Returns a future for a checkpointed current instant. */
public static DurableFuture<Instant> nowAsync(String name) {
return checkpointedValueAsync(name, NOW_SUBTYPE, Instant.class, Instant::now);
}

/**
* Returns a checkpointed pseudorandom {@code double} between {@code 0.0} (inclusive) and {@code 1.0} (exclusive)
* using the default operation name {@code random}.
*/
public static double random() {
return random(RANDOM_NAME);
}

/**
* Returns a checkpointed pseudorandom {@code double} between {@code 0.0} (inclusive) and {@code 1.0} (exclusive).
*/
public static double random(String name) {
return randomAsync(name).get();
}

/**
* Returns a future for a checkpointed pseudorandom {@code double} between {@code 0.0} (inclusive) and {@code 1.0}
* (exclusive) using the default operation name {@code random}.
*/
public static DurableFuture<Double> randomAsync() {
return randomAsync(RANDOM_NAME);
}

/**
* Returns a future for a checkpointed pseudorandom {@code double} between {@code 0.0} (inclusive) and {@code 1.0}
* (exclusive).
*/
public static DurableFuture<Double> randomAsync(String name) {
return checkpointedValueAsync(name, RANDOM_SUBTYPE, Double.class, () -> ThreadLocalRandom.current()
.nextDouble());
}

private static <T> DurableFuture<T> checkpointedValueAsync(
String name, String subType, Class<T> resultType, Supplier<T> supplier) {
ParameterValidator.validateOperationName(name);
var context = ExtensionContext.getCurrentContext();
var result = context.reserve(name)
.stepAsync(
subType,
TypeToken.get(resultType),
ignored -> CompletableFuture.completedFuture(ExtensionStepResult.succeed(supplier.get())),
DurableStepOperation.extensionConfig(
DurableStepOperation.StepConfig.builder().build()));
return CompletionStageDurableFuture.from(context, STEP.name(), name, result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ void operationFacadesUseSingularClassNames() {
assertFacadeRenamed("DurableWaitForCallbackOperation", "DurableWaitForCallbackOperations");
assertFacadeRenamed("DurableWaitForConditionOperation", "DurableWaitForConditionOperations");
assertFacadeRenamed("DurableWithRetryOperation", "DurableWithRetryOperations");
assertFacadeRenamed("DurableReplaySafeValueOperation", "DurableReplaySafeValueOperations");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.operation;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;

import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import software.amazon.lambda.durable.DurableContext;
import software.amazon.lambda.durable.TypeToken;
import software.amazon.lambda.durable.context.BaseContextImpl;
import software.amazon.lambda.durable.extension.ExtensionContext;
import software.amazon.lambda.durable.extension.ExtensionOperation;
import software.amazon.lambda.durable.extension.ExtensionStepConfig;
import software.amazon.lambda.durable.extension.ExtensionStepFunction;
import software.amazon.lambda.durable.extension.ExtensionStepResult;

class DurableReplaySafeValueOperationTest {
@AfterEach
void clearContext() {
BaseContextImpl.setCurrentContext(null);
}

@Test
void defaultHelpersUseExtensionStepsWithDescriptiveNamesAndSubtypes() {
var context = mockDurableContext();
var uuidReservation = mock(ExtensionOperation.class);
var nowReservation = mock(ExtensionOperation.class);
var randomReservation = mock(ExtensionOperation.class);
var uuid = UUID.fromString("12345678-1234-5678-1234-567812345678");
var now = Instant.parse("2026-09-01T12:00:00Z");
BaseContextImpl.setCurrentContext(context);
when(context.reserve("uuid")).thenReturn(uuidReservation);
when(context.reserve("now")).thenReturn(nowReservation);
when(context.reserve("random")).thenReturn(randomReservation);
when(uuidReservation.stepAsync(
eq("UUID"), eq(TypeToken.get(UUID.class)), any(ExtensionStepFunction.class), any()))
.thenReturn(CompletableFuture.completedFuture(uuid));
when(nowReservation.stepAsync(
eq("Now"), eq(TypeToken.get(Instant.class)), any(ExtensionStepFunction.class), any()))
.thenReturn(CompletableFuture.completedFuture(now));
when(randomReservation.stepAsync(
eq("Random"), eq(TypeToken.get(Double.class)), any(ExtensionStepFunction.class), any()))
.thenReturn(CompletableFuture.completedFuture(0.25));

assertEquals(uuid, DurableReplaySafeValueOperation.uuid());
assertEquals(now, DurableReplaySafeValueOperation.now());
assertEquals(0.25, DurableReplaySafeValueOperation.random());

verify(uuidReservation)
.stepAsync(eq("UUID"), eq(TypeToken.get(UUID.class)), any(ExtensionStepFunction.class), any());
verify(nowReservation)
.stepAsync(eq("Now"), eq(TypeToken.get(Instant.class)), any(ExtensionStepFunction.class), any());
verify(randomReservation)
.stepAsync(eq("Random"), eq(TypeToken.get(Double.class)), any(ExtensionStepFunction.class), any());
}

@Test
void namedAsyncHelpersGenerateExpectedValueTypesAndUseDefaultStepRetries() {
var context = mockDurableContext();
BaseContextImpl.setCurrentContext(context);

assertGeneratedValue(context, "request-id", "UUID", UUID.class, UUID.class);
assertGeneratedValue(context, "created-at", "Now", Instant.class, Instant.class);
var random = assertGeneratedValue(context, "sample", "Random", Double.class, Double.class);
assertTrue((Double) random >= 0.0);
assertTrue((Double) random < 1.0);
}

@Test
void helpersRequireAnActiveDurableContext() {
assertThrows(IllegalStateException.class, DurableReplaySafeValueOperation::uuid);
assertThrows(IllegalStateException.class, DurableReplaySafeValueOperation::now);
assertThrows(IllegalStateException.class, DurableReplaySafeValueOperation::random);
}

private <T> Object assertGeneratedValue(
ExtensionContext context, String name, String subType, Class<T> resultType, Class<?> expectedValueType) {
var reservation = mock(ExtensionOperation.class);
when(context.reserve(name)).thenReturn(reservation);
when(reservation.stepAsync(eq(subType), eq(TypeToken.get(resultType)), any(ExtensionStepFunction.class), any()))
.thenReturn(new CompletableFuture<>());

switch (subType) {
case "UUID" -> DurableReplaySafeValueOperation.uuidAsync(name);
case "Now" -> DurableReplaySafeValueOperation.nowAsync(name);
case "Random" -> DurableReplaySafeValueOperation.randomAsync(name);
default -> throw new AssertionError("Unexpected subtype: " + subType);
}

@SuppressWarnings("unchecked")
var function = (ArgumentCaptor<ExtensionStepFunction<T>>)
(ArgumentCaptor<?>) ArgumentCaptor.forClass(ExtensionStepFunction.class);
var config = ArgumentCaptor.forClass(ExtensionStepConfig.class);
verify(reservation).stepAsync(eq(subType), eq(TypeToken.get(resultType)), function.capture(), config.capture());
assertNotNull(config.getValue().retryStrategy());

var outcome = assertInstanceOf(
ExtensionStepResult.Succeeded.class,
function.getValue().apply(null).toCompletableFuture().join());
return assertInstanceOf(expectedValueType, outcome.value());
}

private ExtensionContext mockDurableContext() {
return (ExtensionContext) mock(
DurableContext.class,
withSettings().extraInterfaces(ExtensionContext.class).defaultAnswer(CALLS_REAL_METHODS));
}
}
Loading