diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index e165d3e78..19f1974be 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -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 diff --git a/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ReplaySafeValueIntegrationTest.java b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ReplaySafeValueIntegrationTest.java new file mode 100644 index 000000000..f196bff6e --- /dev/null +++ b/sdk-integration-tests/src/test/java/software/amazon/lambda/durable/ReplaySafeValueIntegrationTest.java @@ -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) {} +} diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperation.java new file mode 100644 index 000000000..ba3b69f53 --- /dev/null +++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperation.java @@ -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. + * + *

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 uuidAsync() { + return uuidAsync(UUID_NAME); + } + + /** Returns a future for a checkpointed random UUID. */ + public static DurableFuture 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 nowAsync() { + return nowAsync(NOW_NAME); + } + + /** Returns a future for a checkpointed current instant. */ + public static DurableFuture 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 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 randomAsync(String name) { + return checkpointedValueAsync(name, RANDOM_SUBTYPE, Double.class, () -> ThreadLocalRandom.current() + .nextDouble()); + } + + private static DurableFuture checkpointedValueAsync( + String name, String subType, Class resultType, Supplier 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); + } +} diff --git a/sdk/src/test/java/software/amazon/lambda/durable/DurableOperationFacadeTest.java b/sdk/src/test/java/software/amazon/lambda/durable/DurableOperationFacadeTest.java index 4d2cd4319..0b1b5351d 100644 --- a/sdk/src/test/java/software/amazon/lambda/durable/DurableOperationFacadeTest.java +++ b/sdk/src/test/java/software/amazon/lambda/durable/DurableOperationFacadeTest.java @@ -71,6 +71,7 @@ void operationFacadesUseSingularClassNames() { assertFacadeRenamed("DurableWaitForCallbackOperation", "DurableWaitForCallbackOperations"); assertFacadeRenamed("DurableWaitForConditionOperation", "DurableWaitForConditionOperations"); assertFacadeRenamed("DurableWithRetryOperation", "DurableWithRetryOperations"); + assertFacadeRenamed("DurableReplaySafeValueOperation", "DurableReplaySafeValueOperations"); } @Test diff --git a/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperationTest.java b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperationTest.java new file mode 100644 index 000000000..b2ae4cdba --- /dev/null +++ b/sdk/src/test/java/software/amazon/lambda/durable/operation/DurableReplaySafeValueOperationTest.java @@ -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 Object assertGeneratedValue( + ExtensionContext context, String name, String subType, Class 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>) + (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)); + } +}