From ec232dbfdbe29eb47274e30049c36540e019eae0 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 11 May 2026 17:05:30 -0400 Subject: [PATCH 01/16] Add durable execution Step + Wait end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the minimum viable slice of the Amazon.Lambda.DurableExecution SDK: a workflow can run StepAsync and WaitAsync against a real Lambda, with replay-aware checkpointing wired through to the AWS service. Public API surface introduced: - DurableFunction.WrapAsync — entry point that handles the durable execution envelope (input hydration, output construction, status mapping) - IDurableContext.StepAsync / WaitAsync (4 Step overloads, 1 Wait) - StepConfig with serializer hook (retry deferred to follow-up PR) - ICheckpointSerializer interface - [DurableExecution] attribute (recognized by future source generator) - DurableExecutionException base + StepException Internals: - DurableExecutionHandler — Task.WhenAny race between user code and the suspension signal, returning Succeeded/Failed/Pending - ExecutionState — replay-aware operation lookup and pending checkpoint buffer - OperationIdGenerator — deterministic, replay-stable IDs - TerminationManager — TaskCompletionSource-based suspension trigger - LambdaDurableServiceClient — wraps AWSSDK.Lambda's checkpoint and state APIs Tests: - 86 unit tests covering enums, exceptions, models, configs, ID generation, termination, execution state, the handler race, the context (Step + Wait paths), and the WrapAsync entry point - 8 end-to-end integration tests deploying real Lambdas via Docker on the provided.al2023 runtime: StepWaitStep, MultipleSteps, WaitOnly, LongerWait, ReplayDeterminism, RetrySucceeds, RetryExhausts, StepFails Out of scope (follow-up PRs): - IRetryStrategy, ExponentialRetryStrategy, retry decision factories - DefaultJsonCheckpointSerializer - DurableLogger replay-suppression (currently returns NullLogger) - Callbacks, InvokeAsync, ParallelAsync, MapAsync, RunInChildContextAsync, WaitForConditionAsync — interface intentionally does not declare them - Annotations source-generator integration - DurableTestRunner / Amazon.Lambda.DurableExecution.Testing package - dotnet new lambda.DurableFunction blueprint stack-info: PR: https://github.com/aws/aws-lambda-dotnet/pull/2360, branch: GarrettBeatty/stack/2 remove update update update update --- Docs/durable-execution-design.md | 238 +++++-- .../Amazon.Lambda.DurableExecution.csproj | 2 + .../AssemblyMarker.cs | 5 - .../Config/StepConfig.cs | 13 + .../DurableContext.cs | 147 ++++ .../DurableExecutionHandler.cs | 119 ++++ .../DurableFunction.cs | 338 +++++++++ .../Amazon.Lambda.DurableExecution/Enums.cs | 14 + .../Exceptions/DurableExecutionException.cs | 49 ++ .../ICheckpointSerializer.cs | 25 + .../IDurableContext.cs | 108 +++ .../Internal/CheckpointBatcher.cs | 216 ++++++ .../Internal/CheckpointBatcherConfig.cs | 35 + .../Internal/DurableOperation.cs | 69 ++ .../Internal/ExecutionState.cs | 93 +++ .../Internal/Operation.cs | 140 ++++ .../Internal/OperationIdGenerator.cs | 101 +++ .../ReflectionJsonCheckpointSerializer.cs | 36 + .../Internal/StepOperation.cs | 164 +++++ .../Internal/TerminationManager.cs | 77 ++ .../Internal/UpperSnakeCaseEnumConverter.cs | 64 ++ .../Internal/WaitOperation.cs | 93 +++ .../Models/DurableExecutionInvocationInput.cs | 53 ++ .../DurableExecutionInvocationOutput.cs | 29 + .../Models/ErrorObject.cs | 46 ++ .../Services/LambdaDurableServiceClient.cs | 108 +++ ...bda.DurableExecution.AotPublishTest.csproj | 24 + .../Program.cs | 81 +++ ...a.DurableExecution.IntegrationTests.csproj | 43 ++ .../DurableFunctionDeployment.cs | 468 ++++++++++++ .../LongerWaitTest.cs | 62 ++ .../MultipleStepsTest.cs | 56 ++ .../ReplayDeterminismTest.cs | 67 ++ .../StepFailsTest.cs | 51 ++ .../StepWaitStepTest.cs | 58 ++ .../LongerWaitFunction/Dockerfile | 7 + .../LongerWaitFunction/Function.cs | 40 ++ .../LongerWaitFunction.csproj | 18 + .../MultipleStepsFunction/Dockerfile | 7 + .../MultipleStepsFunction/Function.cs | 50 ++ .../MultipleStepsFunction.csproj | 18 + .../ReplayDeterminismFunction/Dockerfile | 7 + .../ReplayDeterminismFunction/Function.cs | 43 ++ .../ReplayDeterminismFunction.csproj | 18 + .../StepFailsFunction/Dockerfile | 7 + .../StepFailsFunction/Function.cs | 38 + .../StepFailsFunction.csproj | 18 + .../StepWaitStepFunction/Dockerfile | 7 + .../StepWaitStepFunction/Function.cs | 40 ++ .../StepWaitStepFunction.csproj | 18 + .../TestFunctions/WaitOnlyFunction/Dockerfile | 7 + .../WaitOnlyFunction/Function.cs | 31 + .../WaitOnlyFunction/WaitOnlyFunction.csproj | 18 + .../WaitOnlyTest.cs | 55 ++ .../xunit.runner.json | 6 + ...mazon.Lambda.DurableExecution.Tests.csproj | 5 +- .../AssemblyLoadTests.cs | 13 - .../CheckpointBatcherTests.cs | 213 ++++++ .../ConfigTests.cs | 15 + .../DurableContextTests.cs | 669 ++++++++++++++++++ .../DurableExecutionHandlerTests.cs | 137 ++++ .../DurableFunctionTests.cs | 583 +++++++++++++++ .../EnumsTests.cs | 39 + .../ExceptionsTests.cs | 68 ++ .../ExecutionStateTests.cs | 165 +++++ .../LambdaDurableServiceClientTests.cs | 202 ++++++ .../MockLambdaClient.cs | 65 ++ .../ModelsTests.cs | 203 ++++++ .../OperationIdGeneratorTests.cs | 100 +++ .../RecordingBatcher.cs | 51 ++ .../TerminationManagerTests.cs | 88 +++ .../UpperSnakeCaseEnumConverterTests.cs | 84 +++ .../coverage.runsettings | 15 + .../coverage.sh | 29 + 74 files changed, 6428 insertions(+), 61 deletions(-) delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/AssemblyMarker.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionHandler.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcher.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcherConfig.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/TerminationManager.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Amazon.Lambda.DurableExecution.AotPublishTest.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/LongerWaitTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MultipleStepsTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ReplayDeterminismTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepFailsTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepWaitStepTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/LongerWaitFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/MultipleStepsFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/ReplayDeterminismFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/StepFailsFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/StepWaitStepFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/WaitOnlyFunction.csproj create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitOnlyTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/xunit.runner.json delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/AssemblyLoadTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/CheckpointBatcherTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableExecutionHandlerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/EnumsTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExceptionsTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/LambdaDurableServiceClientTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/MockLambdaClient.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/ModelsTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/RecordingBatcher.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/TerminationManagerTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.runsettings create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.sh diff --git a/Docs/durable-execution-design.md b/Docs/durable-execution-design.md index efaa41589..6df424c5f 100644 --- a/Docs/durable-execution-design.md +++ b/Docs/durable-execution-design.md @@ -158,7 +158,7 @@ public class Function { // Step 1: Validate the order (checkpointed automatically) var validation = await context.StepAsync( - async () => await ValidateOrder(input.OrderId), + async (step) => await ValidateOrder(input.OrderId), name: "validate_order"); if (!validation.IsValid) @@ -169,7 +169,7 @@ public class Function // Step 3: Process the order var result = await context.StepAsync( - async () => await ProcessOrder(input.OrderId), + async (step) => await ProcessOrder(input.OrderId), name: "process_order"); return new OrderResult { Status = "approved", OrderId = result.OrderId }; @@ -182,6 +182,7 @@ public class Function Things to notice: - `[LambdaFunction]` + `[DurableExecution]` triggers source generation, so you don't wire up the handler yourself +- Each step function receives an `IStepContext` with a step-scoped logger, attempt number, and operation ID - Each `StepAsync` call checkpoints its result automatically - `WaitAsync` suspends the function -- Lambda is not running (or billing you) during the wait - On replay, completed steps return their cached result without re-executing @@ -208,7 +209,7 @@ public class Function private async Task MyWorkflow(OrderEvent input, IDurableContext context) { var validation = await context.StepAsync( - async () => await ValidateOrder(input.OrderId), + async (step) => await ValidateOrder(input.OrderId), name: "validate_order"); if (!validation.IsValid) @@ -217,7 +218,7 @@ public class Function await context.WaitAsync(TimeSpan.FromSeconds(30), name: "processing_delay"); var result = await context.StepAsync( - async () => await ProcessOrder(input.OrderId), + async (step) => await ProcessOrder(input.OrderId), name: "process_order"); return new OrderResult { Status = "approved", OrderId = result.OrderId }; @@ -244,9 +245,46 @@ public Task FunctionHandler( private async Task MyWorkflow(OrderEvent input, IDurableContext context) { - await context.StepAsync(async () => await SendNotification(input.UserId), name: "notify"); + await context.StepAsync(async (step) => await SendNotification(input.UserId), name: "notify"); await context.WaitAsync(TimeSpan.FromHours(1), name: "cooldown"); - await context.StepAsync(async () => await Cleanup(input.UserId), name: "cleanup"); + await context.StepAsync(async (step) => await Cleanup(input.UserId), name: "cleanup"); +} +``` + +For **NativeAOT** deployments, pass a `JsonSerializerContext` so the SDK can serialize/deserialize your input and output types without reflection: + +```csharp +[JsonSerializable(typeof(OrderEvent))] +[JsonSerializable(typeof(OrderResult))] +internal partial class MyJsonContext : JsonSerializerContext { } + +public class Function +{ + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, MyJsonContext.Default); + + private async Task MyWorkflow(OrderEvent input, IDurableContext context) + { + // ... + } +} +``` + +To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), use the overload that accepts one: + +```csharp +public class Function +{ + private readonly IAmazonLambda _lambdaClient; + + public Function(IAmazonLambda lambdaClient) => _lambdaClient = lambdaClient; + + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, _lambdaClient); } ``` @@ -422,7 +460,7 @@ var approval = await context.WaitForCallbackAsync( if (approval.Approved) { - await context.StepAsync(async () => await ExecutePlan(), name: "execute"); + await context.StepAsync(async (step) => await ExecutePlan(), name: "execute"); } ``` @@ -486,9 +524,9 @@ Run independent operations concurrently. The JS SDK uses a `DurablePromise` patt var results = await context.ParallelAsync( new Func>[] { - async (ctx) => await ctx.StepAsync(async () => await FetchUserData(userId), name: "fetch_user"), - async (ctx) => await ctx.StepAsync(async () => await FetchOrderHistory(userId), name: "fetch_orders"), - async (ctx) => await ctx.StepAsync(async () => await FetchPreferences(userId), name: "fetch_prefs"), + async (ctx) => await ctx.StepAsync(async (step) => await FetchUserData(userId), name: "fetch_user"), + async (ctx) => await ctx.StepAsync(async (step) => await FetchOrderHistory(userId), name: "fetch_orders"), + async (ctx) => await ctx.StepAsync(async (step) => await FetchPreferences(userId), name: "fetch_prefs"), }, name: "parallel_fetch", config: new ParallelConfig @@ -512,9 +550,9 @@ For better observability, you can name individual branches (matching the JS SDK var results = await context.ParallelAsync( new NamedBranch[] { - new("fetch_user", async (ctx) => await ctx.StepAsync(async () => await FetchUserData(userId))), - new("fetch_orders", async (ctx) => await ctx.StepAsync(async () => await FetchOrderHistory(userId))), - new("fetch_prefs", async (ctx) => await ctx.StepAsync(async () => await FetchPreferences(userId))), + new("fetch_user", async (ctx) => await ctx.StepAsync(async (step) => await FetchUserData(userId))), + new("fetch_orders", async (ctx) => await ctx.StepAsync(async (step) => await FetchOrderHistory(userId))), + new("fetch_prefs", async (ctx) => await ctx.StepAsync(async (step) => await FetchPreferences(userId))), }, name: "parallel_fetch"); @@ -884,7 +922,7 @@ When user code hits a pending wait or callback: 2. Calls `terminationManager.Terminate(WaitScheduled)` 3. Awaits a new never-completing `TaskCompletionSource` (blocks itself permanently) 4. `Task.WhenAny` sees the termination task resolved and picks it as the winner -5. `RunAsync` returns PENDING; Lambda terminates; the abandoned user task is GC'd +5. `RunAsync` returns PENDING; the abandoned user task is left to be GC'd; Lambda terminates ### Lifecycle and cleanup @@ -906,21 +944,95 @@ Static helper for the non-Annotations handler path. Wraps a workflow function, h /// public static class DurableFunction { + // ── Reflection-based overloads (JIT only) ────────────────────────── + /// /// Wrap a workflow that takes typed input and returns typed output. + /// Reflection-based JSON — not AOT-safe. /// + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext); /// - /// Wrap a workflow that takes typed input and returns no value. + /// Wrap a workflow (typed input + output) with explicit Lambda client. + /// Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient); + + /// + /// Wrap a void workflow (typed input, no output). + /// Reflection-based JSON — not AOT-safe. /// + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext); + + /// + /// Wrap a void workflow with explicit Lambda client. + /// Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient); + + // ── AOT-safe overloads (caller supplies JsonSerializerContext) ────── + + /// + /// Wrap a workflow (typed input + output). AOT-safe — requires + /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] + /// on the supplied jsonContext. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext); + + /// + /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext); + + /// + /// Wrap a void workflow (typed input, no output). AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext); + + /// + /// Wrap a void workflow with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext); } ``` @@ -948,11 +1060,18 @@ public interface IDurableContext /// ILambdaContext LambdaContext { get; } + // ── StepAsync overloads ──────────────────────────────────────────── + // The user's function always receives IStepContext, matching the + // Python and JS SDKs (Java has no-context overloads but deprecated + // them — see https://github.com/aws/aws-durable-execution-sdk-java). + /// - /// Execute a step with automatic checkpointing. + /// Execute a step with automatic checkpointing using reflection-based JSON. /// The IStepContext provides a step-scoped logger with operation metadata /// (step name, attempt number, operation ID) and the current attempt number. /// + [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] + [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] Task StepAsync( Func> func, string? name = null, @@ -960,7 +1079,7 @@ public interface IDurableContext CancellationToken cancellationToken = default); /// - /// Execute a step that returns no value. + /// Execute a step that returns no value. AOT-safe (no payload to serialize). /// Task StepAsync( Func func, @@ -968,6 +1087,17 @@ public interface IDurableContext StepConfig? config = null, CancellationToken cancellationToken = default); + /// + /// Execute a step with AOT-safe checkpoint serialization. The supplied + /// serializer is used in place of reflection-based JSON. + /// + Task StepAsync( + Func> func, + ICheckpointSerializer serializer, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); + /// /// Suspend execution for the specified duration. /// Throws ArgumentOutOfRangeException if duration is less than 1 second. @@ -1087,7 +1217,9 @@ public record DurableBranch(string Name, Func> Func) #### CancellationToken behavior -All methods accept a `CancellationToken` that follows standard .NET semantics: cancellation throws `OperationCanceledException` and the execution fails. Cancellation does **not** trigger suspension — those are separate concepts. The durable execution service handles timeout scenarios automatically: if Lambda terminates mid-execution, the next invocation simply replays from the last checkpoint. For advanced users who want to suspend gracefully before timeout, check `context.LambdaContext.RemainingTime` and return early. +All methods accept a per-call `CancellationToken` that follows standard .NET semantics: cancellation throws `OperationCanceledException` and the execution fails. Cancellation does **not** trigger suspension — those are separate concepts. + +The durable execution service handles timeout scenarios automatically: if Lambda terminates mid-execution, the next invocation simply replays from the last checkpoint. For advanced users who want to suspend gracefully before timeout, check `context.LambdaContext.RemainingTime` and return early. ### Configuration Types @@ -1112,10 +1244,11 @@ public class StepConfig /// public StepSemantics Semantics { get; set; } = StepSemantics.AtLeastOncePerRetry; - /// - /// Custom serializer for the step result. Default is System.Text.Json. - /// - public ICheckpointSerializer? Serializer { get; set; } + // Note: there is no Serializer property here. Custom serializers are + // supplied via the AOT-safe StepAsync(..., ICheckpointSerializer, ...) + // overload, which is type-safe (ICheckpointSerializer instead of the + // non-generic marker) and gives one obvious way to opt into custom or + // AOT-friendly serialization. } public enum StepSemantics @@ -1543,16 +1676,17 @@ public interface ICheckpointSerializer public record SerializationContext(string OperationId, string DurableExecutionArn); ``` -Usage: +Usage — pass the serializer to the AOT-safe `StepAsync` overload directly. +This is the only way to override the default reflection-based JSON path; it's +intentional that there's no `StepConfig.Serializer` knob, so you have one +obvious place to opt in (and the type is `ICheckpointSerializer`, not the +non-generic marker, so the compiler catches a mismatched `T`): ```csharp var result = await context.StepAsync( async () => await GetLargeData(), - name: "get_data", - config: new StepConfig - { - Serializer = new CompressedJsonSerializer() - }); + new CompressedJsonSerializer(), + name: "get_data"); ``` ### Class library vs. executable output @@ -1579,16 +1713,34 @@ Both approaches produce a self-contained executable that the Lambda custom runti ### NativeAOT compatibility -The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. For NativeAOT deployments, provide a `JsonSerializerContext` via the `ICheckpointSerializer` interface — this avoids all runtime reflection and is fully trim-safe. The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. +The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. For NativeAOT deployments, AOT safety is addressed at two levels — **at each level there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` and an AOT-safe one that requires a serializer parameter**. The trimmer warns at the call site when reflection overloads are used in AOT/trimmed builds. + +1. **Entry point (`DurableFunction.WrapAsync`)** — the AOT-safe overload takes a `JsonSerializerContext` parameter that includes type info for your `TInput` and `TOutput` types. + +2. **Step checkpoints (`IDurableContext.StepAsync`)** — the AOT-safe overload takes an `ICheckpointSerializer` directly as a parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. + +The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. ```csharp -// Default: works with reflection (JIT mode) -var result = await context.StepAsync(async () => await GetOrder()); +// Default: works with reflection (JIT mode); flagged for AOT. +var result = await context.StepAsync(async (step) => await GetOrder()); -// AOT mode: user provides serialization context +// AOT mode — entry point: pass JsonSerializerContext to WrapAsync. +[JsonSerializable(typeof(OrderEvent))] +[JsonSerializable(typeof(OrderResult))] +[JsonSerializable(typeof(Order))] +internal partial class MyJsonContext : JsonSerializerContext { } + +public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, MyJsonContext.Default); + +// AOT mode — step checkpoint: pass ICheckpointSerializer to StepAsync directly. var result = await context.StepAsync( async () => await GetOrder(), - config: new StepConfig { Serializer = new JsonCheckpointSerializer(MyJsonContext.Default.Order) }); + new JsonCheckpointSerializer(MyJsonContext.Default.Order), + name: "get_order"); ``` ### Large payload and checkpoint overflow @@ -1701,7 +1853,7 @@ public class Functions } ``` -When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path, pass the client directly to `DurableExecutionHandler.RunAsync`. +When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path (`DurableFunction.WrapAsync`), pass the client directly via the `IAmazonLambda lambdaClient` overload. > **Dependency boundaries:** `Amazon.Lambda.Annotations` has **no dependency** on the AWS SDK or on `Amazon.Lambda.DurableExecution`. The Annotations source generator references durable execution types by fully-qualified name strings only — it never takes a compile-time dependency on the durable package. The `[DurableExecution]` attribute is defined in `Amazon.Lambda.DurableExecution`, and the generated code resolves against the user's project references. There is only one source generator (Annotations) — no coordination between multiple generators is needed. @@ -1909,11 +2061,11 @@ These analyzers run at compile time in the IDE (IntelliSense squiggles) and duri ## Cross-SDK API comparison -All three SDKs expose the same core operations. The differences are naming conventions, parameter ordering, and concurrency model. +All four SDKs expose the same core operations. The differences are naming conventions, parameter ordering, and concurrency model. -| Operation | .NET | Python | JavaScript | -|-----------|------|--------|------------| -| Step | `context.StepAsync(func, name?, config?)` | `context.step(func, name?, config?)` | `context.step(name?, fn, config?)` → `DurablePromise` | +| Operation | .NET | Python | JavaScript | Java | +|-----------|------|--------|------------|------| +| Step | `context.StepAsync(func, name?, config?)` | `context.step(func, name?, config?)` | `context.step(name?, fn, config?)` → `DurablePromise` | `context.step(name, type, func, config?)` (blocking) / `context.stepAsync(...)` → `DurableFuture` | | Wait | `context.WaitAsync(duration, name?)` | `context.wait(duration, name?)` | `context.wait(name?, duration)` → `DurablePromise` | | Create callback | `context.CreateCallbackAsync(name?, config?)` | `context.create_callback(name?, config?)` | `context.createCallback(name?, config?)` | | Wait for callback | `context.WaitForCallbackAsync(submitter, name?, config?)` | `context.wait_for_callback(submitter, name?, config?)` | `context.waitForCallback(name?, submitter, config?)` | @@ -1943,11 +2095,13 @@ All three SDKs expose the same core operations. The differences are naming conve **Key differences:** -- **Concurrency model:** JS returns `DurablePromise` (lazy, deferred until awaited). Python is synchronous (blocks the thread). .NET returns `Task` (standard async/await). Note: `Task.WhenAll` works with durable operations but `ParallelAsync`/`MapAsync` are preferred for completion policies and observability. -- **Name parameter position:** JS puts `name` first; Python and .NET put it after the function/duration. -- **Parallel semantics in JS:** JS uses `context.promise.all/any/race/allSettled` to combine DurablePromises. .NET and Python use `CompletionConfig` on the `Parallel`/`Map` operations instead. +- **Concurrency model:** JS returns `DurablePromise` (lazy, deferred until awaited). Python is synchronous (blocks the thread). Java exposes both `step` (blocking) and `stepAsync` (returns `DurableFuture`). .NET returns `Task` (standard async/await). Note: `Task.WhenAll` works with durable operations but `ParallelAsync`/`MapAsync` are preferred for completion policies and observability. +- **Why .NET ships only the async form:** Java's two-API split exists because Java has no language-level `await` — `step` is the simple blocking ergonomic, `stepAsync` is the composable form. In .NET, `Task` is *already* both: `await context.StepAsync(...)` reads as sequential code, and `Task.WhenAll(...)` composes concurrently. A `Step` (blocking, returns `T`) overload would do nothing except call `.GetAwaiter().GetResult()` on the async version, which is also a Lambda-thread anti-pattern (deadlock-prone, blocks a thread the runtime needs). So .NET intentionally has one shape — `*Async` — matching the rest of `IAmazonLambda` and the broader .NET async convention. Python is single-shape for the same reason in reverse: no async runtime in scope, so blocking is the only ergonomic shape. +- **Step function signature:** Python and JS only expose `Func` — the user always receives a step context. Java has both `Function` and `Supplier` overloads, but the `Supplier` ones are deprecated (*"use the variants accepting StepContext instead"*). .NET follows Python/JS: `IStepContext` is always passed. +- **Name parameter position:** JS puts `name` first; Python, Java, and .NET put it after the function/duration. +- **Parallel semantics in JS:** JS uses `context.promise.all/any/race/allSettled` to combine DurablePromises. .NET, Python, and Java use `CompletionConfig` on the `Parallel`/`Map` operations instead. - **.NET-only:** `CancellationToken` on every method (standard .NET pattern). -- **Jitter default:** All three SDKs default to full jitter on retry strategies. +- **Jitter default:** All four SDKs default to full jitter on retry strategies. --- diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj index 9139edb18..de02d8ce2 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj @@ -14,6 +14,8 @@ true enable enable + true + IL2026,IL2067,IL2075,IL3050 diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/AssemblyMarker.cs b/Libraries/src/Amazon.Lambda.DurableExecution/AssemblyMarker.cs deleted file mode 100644 index 770e6ccd2..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/AssemblyMarker.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Amazon.Lambda.DurableExecution; - -internal static class AssemblyMarker -{ -} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs new file mode 100644 index 000000000..2380967de --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs @@ -0,0 +1,13 @@ +namespace Amazon.Lambda.DurableExecution; + +/// +/// Configuration for step execution. +/// +public sealed class StepConfig +{ + // TODO: Retry support is deferred to a follow-up PR. When added, this is + // where RetryStrategy and Semantics (AtLeastOncePerRetry / AtMostOncePerRetry) + // will live. The follow-up needs to use service-mediated retries (checkpoint + // a RETRY operation + suspend the Lambda) rather than an in-process Task.Delay + // loop, to avoid billing Lambda compute time during retry backoff. +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs new file mode 100644 index 000000000..87a874c2d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -0,0 +1,147 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// Implementation of . Constructs and dispatches +/// per-operation classes (, ); +/// the replay logic lives in those classes. +/// +internal sealed class DurableContext : IDurableContext +{ + private readonly ExecutionState _state; + private readonly TerminationManager _terminationManager; + private readonly OperationIdGenerator _idGenerator; + private readonly string _durableExecutionArn; + private readonly CheckpointBatcher? _batcher; + + public DurableContext( + ExecutionState state, + TerminationManager terminationManager, + OperationIdGenerator idGenerator, + string durableExecutionArn, + ILambdaContext lambdaContext, + CheckpointBatcher? batcher = null) + { + _state = state; + _terminationManager = terminationManager; + _idGenerator = idGenerator; + _durableExecutionArn = durableExecutionArn; + _batcher = batcher; + LambdaContext = lambdaContext; + } + + // Replay-safe logger ships in a follow-up PR; see IDurableContext.Logger doc. + public ILogger Logger => NullLogger.Instance; + public IExecutionContext ExecutionContext => new DurableExecutionContext(_durableExecutionArn); + public ILambdaContext LambdaContext { get; } + + [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] + [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] + public Task StepAsync( + Func> func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default) + => RunStep(func, new ReflectionJsonCheckpointSerializer(), name, config, cancellationToken); + + public async Task StepAsync( + Func func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default) + { + // Void steps don't carry a meaningful payload; we wrap with a null-only + // serializer that doesn't touch reflection. + await RunStep( + async (ctx) => { await func(ctx); return null; }, + NullCheckpointSerializer.Instance, + name, config, cancellationToken); + } + + public Task StepAsync( + Func> func, + ICheckpointSerializer serializer, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default) + => RunStep(func, serializer, name, config, cancellationToken); + + + private Task RunStep( + Func> func, + ICheckpointSerializer serializer, + string? name, + StepConfig? config, + CancellationToken cancellationToken) + { + var operationId = _idGenerator.NextId(); + var op = new StepOperation( + operationId, name, func, config, serializer, Logger, + _state, _terminationManager, _durableExecutionArn, _batcher); + return op.ExecuteAsync(cancellationToken); + } + + public Task WaitAsync( + TimeSpan duration, + string? name = null, + CancellationToken cancellationToken = default) + { + // Service timer granularity is 1 second; sub-second waits would round to 0. + // WaitOptions.WaitSeconds is integer in [1, 31_622_400] (1 second to ~1 year). + if (duration < TimeSpan.FromSeconds(1)) + throw new ArgumentOutOfRangeException(nameof(duration), duration, "Wait duration must be at least 1 second."); + + if (duration > TimeSpan.FromSeconds(31_622_400)) + throw new ArgumentOutOfRangeException(nameof(duration), duration, "Wait duration must be at most 31,622,400 seconds (~1 year)."); + + cancellationToken.ThrowIfCancellationRequested(); + + var operationId = _idGenerator.NextId(); + var waitSeconds = (int)Math.Max(1, Math.Ceiling(duration.TotalSeconds)); + var op = new WaitOperation( + operationId, name, waitSeconds, + _state, _terminationManager, _durableExecutionArn, _batcher); + return op.ExecuteAsync(cancellationToken); + } +} + +/// +/// Trim-safe serializer used by the void StepAsync overloads, which never +/// carry a meaningful payload. Always serializes to "null" and discards +/// on deserialize. +/// +internal sealed class NullCheckpointSerializer : ICheckpointSerializer +{ + public static NullCheckpointSerializer Instance { get; } = new(); + public string Serialize(object? value, SerializationContext context) => "null"; + public object? Deserialize(string data, SerializationContext context) => null; +} + +internal sealed class DurableExecutionContext : IExecutionContext +{ + public DurableExecutionContext(string durableExecutionArn) + { + DurableExecutionArn = durableExecutionArn; + } + + public string DurableExecutionArn { get; } +} + +internal sealed class StepContext : IStepContext +{ + public StepContext(string operationId, int attemptNumber, ILogger logger) + { + OperationId = operationId; + AttemptNumber = attemptNumber; + Logger = logger; + } + + public ILogger Logger { get; } + public int AttemptNumber { get; } + public string OperationId { get; } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionHandler.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionHandler.cs new file mode 100644 index 000000000..300cc8654 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionHandler.cs @@ -0,0 +1,119 @@ +using Amazon.Lambda.DurableExecution.Internal; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// The result of running a durable execution handler. +/// +internal sealed class HandlerResult +{ + public required InvocationStatus Status { get; init; } + public TResult? Result { get; init; } + public string? Message { get; init; } + public Exception? Exception { get; init; } +} + +/// +/// Core orchestration engine for durable execution. Races user code against +/// a termination signal using Task.WhenAny. When user code completes, returns +/// SUCCEEDED/FAILED. When termination wins (wait, callback, invoke), returns PENDING. +/// +internal static class DurableExecutionHandler +{ + /// + /// Runs the user's workflow function within the durable execution engine. + /// + /// + /// + /// Suspension flow — example: await ctx.WaitAsync(TimeSpan.FromSeconds(5)): + /// + /// + /// user code DurableContext TerminationMgr RunAsync + /// ───────── ────────────── ────────────── ──────── + /// WaitAsync(5s) ─────► queue WAIT START + /// checkpoint + /// Terminate() ──────► TerminationTask + /// completes + /// ◄────── new TCS().Task + /// (never completes) + /// await blocks + /// forever WhenAny: + /// ── termination wins + /// ── userTask abandoned + /// ── return Pending + /// + /// + /// Key insight: WaitAsync never returns a completed Task — it hands back + /// a TaskCompletionSource that is never resolved. The user's await blocks + /// indefinitely. The escape signal is terminationManager.Terminate(), + /// which Task.WhenAny picks up. We return Pending; the dangling user + /// Task is GC'd. The service flushes checkpoints, fires the wait timer, then + /// re-invokes Lambda — on replay, WaitAsync sees the matching SUCCEED + /// checkpoint and returns Task.CompletedTask normally. + /// + /// + /// The same pattern applies to retries (RetryScheduled), callbacks + /// (CallbackPending), and chained invokes (InvokePending). + /// + /// + /// The workflow return type. + /// Hydrated execution state from prior invocations. + /// Manages the suspension signal. + /// The user's workflow function receiving a DurableContext. + /// The handler result indicating SUCCEEDED, FAILED, or PENDING. + internal static async Task> RunAsync( + ExecutionState executionState, + TerminationManager terminationManager, + Func> userHandler) + { + // Run user code on a threadpool thread so it executes independently of + // the termination signal. When TerminationManager fires (e.g., WaitAsync), + // we need the WhenAny race below to resolve immediately without waiting + // for the user task to reach an await point. + var userTask = Task.Run(userHandler); + + // Race: user code completing vs. termination signal (wait/callback/retry). + // If termination wins, we return PENDING and the abandoned userTask is never awaited. + var winner = await Task.WhenAny(userTask, terminationManager.TerminationTask); + + if (winner == terminationManager.TerminationTask) + { + var terminationResult = await terminationManager.TerminationTask; + + if (terminationResult.Exception != null) + { + return new HandlerResult + { + Status = InvocationStatus.Failed, + Message = terminationResult.Exception.Message, + Exception = terminationResult.Exception + }; + } + + return new HandlerResult + { + Status = InvocationStatus.Pending, + Message = terminationResult.Message + }; + } + + try + { + var result = await userTask; + return new HandlerResult + { + Status = InvocationStatus.Succeeded, + Result = result + }; + } + catch (Exception ex) + { + return new HandlerResult + { + Status = InvocationStatus.Failed, + Message = ex.Message, + Exception = ex + }; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs new file mode 100644 index 000000000..d629a0b2e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -0,0 +1,338 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using Amazon.Lambda; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.DurableExecution.Services; +using Amazon.Lambda.Model; +using Amazon.Runtime; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// Static helper that wraps a durable workflow function, handling all envelope +/// translation between DurableExecutionInvocationInput/Output and user types. +/// +public static class DurableFunction +{ + private static readonly Lazy _cachedLambdaClient = + new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); + + // ────────────────────────────────────────────────────────────────────── + // Reflection-based overloads (JIT only) + // ────────────────────────────────────────────────────────────────────── + + /// + /// Wrap a workflow (typed input + output). Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) + { + return WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext: null); + } + + /// + /// Wrap a workflow (typed input + output) with explicit Lambda client. + /// Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient) + => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient, jsonContext: null); + + /// + /// Wrap a void workflow (typed input, no output). Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) + { + return WrapAsync(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); + } + + /// + /// Wrap a void workflow with explicit Lambda client. Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient) + => WrapAsyncCore( + async (input, ctx) => { await workflow(input, ctx); return null; }, + invocationInput, lambdaContext, lambdaClient, jsonContext: null); + + // ────────────────────────────────────────────────────────────────────── + // AOT-safe overloads (caller supplies JsonSerializerContext) + // ────────────────────────────────────────────────────────────────────── + + /// + /// Wrap a workflow (typed input + output). AOT-safe — requires + /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] + /// on the supplied . + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext) + { + return WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext); + } + + /// + /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext) + => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient, jsonContext); + + /// + /// Wrap a void workflow (typed input, no output). AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext) + { + return WrapAsyncCore( + async (input, ctx) => { await workflow(input, ctx); return null; }, + invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext); + } + + /// + /// Wrap a void workflow with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext) + => WrapAsyncCore( + async (input, ctx) => { await workflow(input, ctx); return null; }, + invocationInput, lambdaContext, lambdaClient, jsonContext); + + // ────────────────────────────────────────────────────────────────────── + // Core implementation + // ────────────────────────────────────────────────────────────────────── + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "When jsonContext is non-null, dispatch goes through JsonTypeInfo; when null, the caller has [RequiresUnreferencedCode].")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "When jsonContext is non-null, dispatch goes through JsonTypeInfo; when null, the caller has [RequiresDynamicCode].")] + private static async Task WrapAsyncCore( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext? jsonContext) + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(invocationInput.InitialExecutionState); + + var serviceClient = new LambdaDurableServiceClient(lambdaClient); + var checkpointToken = invocationInput.CheckpointToken; + + var nextMarker = invocationInput.InitialExecutionState?.NextMarker; + while (!string.IsNullOrEmpty(nextMarker)) + { + var (operations, marker) = await serviceClient.GetExecutionStateAsync( + invocationInput.DurableExecutionArn, checkpointToken, nextMarker); + state.AddOperations(operations); + nextMarker = marker; + } + + var userPayload = ExtractUserPayload(invocationInput, jsonContext); + var terminationManager = new TerminationManager(); + var idGenerator = new OperationIdGenerator(); + + await using var batcher = new CheckpointBatcher( + checkpointToken, + (token, ops, ct) => serviceClient.CheckpointAsync( + invocationInput.DurableExecutionArn, token, ops, ct)); + + var context = new DurableContext( + state, terminationManager, idGenerator, + invocationInput.DurableExecutionArn, lambdaContext, batcher); + + HandlerResult result; + try + { + result = await DurableExecutionHandler.RunAsync( + state, terminationManager, + async () => await workflow(userPayload, context)); + + await batcher.DrainAsync(); + } + catch (AmazonServiceException ex) when (IsTerminalCheckpointError(ex)) + { + return new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Failed, + Error = ErrorObject.FromException(ex) + }; + } + + return MapToOutput(result, jsonContext); + } + + /// + /// Returns true for checkpoint-flush SDK errors that should fail the workflow + /// (Failed envelope) instead of escaping to the host (Lambda retry). + /// + /// + /// Classification rule (mirrors CheckpointError in aws-durable-execution-sdk-python): + /// - 4xx (except 429) → terminal: permanent caller-side failure (missing ARN/KMS key, + /// IAM denial, validation). Retrying will not fix it, so return Failed. + /// - 429 / 5xx / no status (network or SDK-internal) → not terminal: transient, + /// allow the exception to escape so Lambda retries the invocation. + /// - Carve-out: InvalidParameterValueException with a message starting with + /// "Invalid Checkpoint Token" is treated as transient — the service rejects a + /// stale token but a retry with a fresh token will succeed. + /// + /// Only checkpoint-flush errors flow through this catch. There are two paths: + /// 1. A flush triggered synchronously from inside a user StepAsync call + /// (the user awaits EnqueueAsync → batch flush → SDK throws). + /// 2. The final after the workflow returns. + /// + /// State-hydration errors (GetExecutionStateAsync) are NOT caught here — they + /// propagate to the host so Lambda retries, matching Python's GetExecutionStateError + /// (which extends InvocationError). + /// + /// User-code SDK errors (e.g. an SDK call inside a Step body) are caught by + /// StepRunner and surfaced as StepException for the workflow's normal + /// step-failure handling. + /// + private static bool IsTerminalCheckpointError(AmazonServiceException ex) + { + var status = (int)ex.StatusCode; + if (status < 400 || status >= 500 || status == 429) + return false; + + if (ex.ErrorCode == "InvalidParameterValueException" + && ex.Message != null + && ex.Message.StartsWith("Invalid Checkpoint Token", StringComparison.Ordinal)) + { + return false; + } + + return true; + } + + // Shared options for both user-payload deserialization (input) and user-result + // serialization (output) so the naming policy stays symmetric. We only enable + // case-insensitive matching here — keep PascalCase on the wire for output to + // preserve compatibility with existing serialized contracts. Only the user payload + // portion uses these options; the durable-execution envelope itself + // (DurableExecutionInvocationInput/Output) is serialized separately and is not + // affected. + private static readonly JsonSerializerOptions UserPayloadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] + // The user's input payload is stored inside the service envelope as an EXECUTION-type + // operation. This is part of the durable execution wire format — each invocation includes + // its input as a checkpoint record so the service can validate replay consistency. + private static TInput ExtractUserPayload( + DurableExecutionInvocationInput input, + JsonSerializerContext? jsonContext) + { + if (input.InitialExecutionState?.Operations == null) + return default!; + + foreach (var op in input.InitialExecutionState.Operations) + { + if (op.Type != OperationTypes.Execution || op.ExecutionDetails?.InputPayload == null) + continue; + + var payload = op.ExecutionDetails.InputPayload; + if (jsonContext != null) + { + if (jsonContext.GetTypeInfo(typeof(TInput)) is JsonTypeInfo typeInfo) + return JsonSerializer.Deserialize(payload, typeInfo) ?? default!; + + throw new InvalidOperationException( + $"JsonSerializerContext {jsonContext.GetType().FullName} has no JsonTypeInfo for {typeof(TInput).FullName}. " + + "Add [JsonSerializable(typeof(YourInput))] to your context."); + } + + return JsonSerializer.Deserialize(payload, UserPayloadOptions) ?? default!; + } + + return default!; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] + private static DurableExecutionInvocationOutput MapToOutput( + HandlerResult result, + JsonSerializerContext? jsonContext) + { + return result.Status switch + { + InvocationStatus.Succeeded => new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Succeeded, + Result = SerializeOutput(result.Result, jsonContext) + }, + InvocationStatus.Failed => new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Failed, + Error = result.Exception != null + ? ErrorObject.FromException(result.Exception) + : new ErrorObject { ErrorMessage = result.Message } + }, + // Pending = workflow suspended (wait/retry/callback). No Result or Error — + // the service will re-invoke with accumulated checkpoints when ready. + InvocationStatus.Pending => new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Pending + }, + _ => throw new InvalidOperationException($"Unexpected status: {result.Status}") + }; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] + private static string? SerializeOutput(TOutput? value, JsonSerializerContext? jsonContext) + { + if (value == null) return null; + + if (jsonContext != null) + { + if (jsonContext.GetTypeInfo(typeof(TOutput)) is JsonTypeInfo typeInfo) + return JsonSerializer.Serialize(value, typeInfo); + + throw new InvalidOperationException( + $"JsonSerializerContext {jsonContext.GetType().FullName} has no JsonTypeInfo for {typeof(TOutput).FullName}. " + + "Add [JsonSerializable(typeof(YourOutput))] to your context."); + } + + return JsonSerializer.Serialize(value, UserPayloadOptions); + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs new file mode 100644 index 000000000..c1bf44403 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs @@ -0,0 +1,14 @@ +namespace Amazon.Lambda.DurableExecution; + +/// +/// The terminal status of a durable execution invocation. +/// +public enum InvocationStatus +{ + /// The workflow completed successfully. + Succeeded, + /// The workflow failed with an unhandled exception. + Failed, + /// The workflow suspended (waiting for time, callback, or invocation). + Pending +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs new file mode 100644 index 000000000..0f724b4a2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs @@ -0,0 +1,49 @@ +namespace Amazon.Lambda.DurableExecution; + +/// +/// Base exception for all durable execution errors. +/// +public class DurableExecutionException : Exception +{ + /// Creates an empty . + public DurableExecutionException() { } + /// Creates a with the given message. + public DurableExecutionException(string message) : base(message) { } + /// Creates a wrapping an inner exception. + public DurableExecutionException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Thrown when code has changed between invocations, causing a replay mismatch. +/// For example, a step at index 0 was previously a WAIT but is now a STEP. +/// +public class NonDeterministicExecutionException : DurableExecutionException +{ + /// Creates an empty . + public NonDeterministicExecutionException() { } + /// Creates a with the given message. + public NonDeterministicExecutionException(string message) : base(message) { } + /// Creates a wrapping an inner exception. + public NonDeterministicExecutionException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Thrown when user code inside a step fails (after retries exhausted). +/// Contains the original error details from the checkpoint. +/// +public class StepException : DurableExecutionException +{ + /// The fully-qualified type name of the original exception. + public string? ErrorType { get; init; } + /// Optional structured error data attached by the user. + public string? ErrorData { get; init; } + /// Stack trace of the original exception, captured before serialization. + public IReadOnlyList? OriginalStackTrace { get; init; } + + /// Creates an empty . + public StepException() { } + /// Creates a with the given message. + public StepException(string message) : base(message) { } + /// Creates a wrapping an inner exception. + public StepException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs new file mode 100644 index 000000000..3d7175b4d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs @@ -0,0 +1,25 @@ +namespace Amazon.Lambda.DurableExecution; + +/// +/// Serializes and deserializes checkpoint operation results. +/// +/// The type to serialize. +public interface ICheckpointSerializer +{ + /// + /// Serializes a value for checkpoint storage. + /// + string Serialize(T value, SerializationContext context); + + /// + /// Deserializes a value from checkpoint storage. + /// + T Deserialize(string data, SerializationContext context); +} + +/// +/// Context information available during serialization/deserialization. +/// +/// The deterministic operation ID for this step. +/// The ARN of the current durable execution. +public record SerializationContext(string OperationId, string DurableExecutionArn); diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs new file mode 100644 index 000000000..ff18d1218 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs @@ -0,0 +1,108 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.Lambda.Core; +using Microsoft.Extensions.Logging; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// The primary interface for durable execution operations. +/// Passed to user workflow functions to access checkpointed steps and waits. +/// Additional operations (callbacks, parallel, map, etc.) are added in +/// follow-up PRs. +/// +public interface IDurableContext +{ + /// + /// A logger scoped to the durable execution. Currently returns + /// ; + /// the replay-safe DurableLogger (suppresses messages during replay) + /// ships in a follow-up PR. + /// + ILogger Logger { get; } + + /// + /// Metadata about the current durable execution. + /// + IExecutionContext ExecutionContext { get; } + + /// + /// The underlying Lambda context. + /// + ILambdaContext LambdaContext { get; } + + /// + /// Execute a step with automatic checkpointing. The step result is serialized + /// to a checkpoint using reflection-based System.Text.Json. + /// For NativeAOT or trimmed deployments, use the overload that takes an + /// . + /// + [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] + [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] + Task StepAsync( + Func> func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); + + /// + /// Execute a step that returns no value. + /// + Task StepAsync( + Func func, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); + + /// + /// Execute a step with AOT-safe checkpoint serialization. The supplied + /// is used in place of reflection-based JSON. + /// + Task StepAsync( + Func> func, + ICheckpointSerializer serializer, + string? name = null, + StepConfig? config = null, + CancellationToken cancellationToken = default); + + /// + /// Suspend execution for the specified duration without consuming compute time. + /// The Lambda is suspended and the service re-invokes it after the wait elapses. + /// Duration must be at least 1 second (service timer granularity). + /// + Task WaitAsync( + TimeSpan duration, + string? name = null, + CancellationToken cancellationToken = default); +} + +/// +/// Context passed to step functions. +/// +public interface IStepContext +{ + /// + /// Logger scoped to this step. + /// + ILogger Logger { get; } + + /// + /// The current retry attempt number (1-based). + /// + int AttemptNumber { get; } + + /// + /// The deterministic operation ID for this step. + /// + string OperationId { get; } +} + +/// +/// Metadata about the current execution. +/// +public interface IExecutionContext +{ + /// + /// The ARN of the current durable execution. + /// + string DurableExecutionArn { get; } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcher.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcher.cs new file mode 100644 index 000000000..8039e7c56 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcher.cs @@ -0,0 +1,216 @@ +using System.Runtime.ExceptionServices; +using System.Threading.Channels; +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Background batcher for outbound checkpoint updates. Operations are enqueued +/// via ; a single worker drains the queue and flushes +/// each batch via the supplied flushAsync delegate. Each EnqueueAsync +/// call awaits the flush of its containing batch (sync semantics). +/// +/// +/// TODO: when Map / Parallel / ChildContext / WaitForCondition land — or when +/// AtLeastOncePerRetry step START gets a non-blocking variant — they will need +/// a fire-and-forget overload like +/// Task EnqueueAsync(SdkOperationUpdate update, bool sync) where +/// sync=false returns as soon as the item is queued. Java's +/// sendOperationUpdate vs sendOperationUpdateAsync is the model. +/// Today every call site is sync, so the API stays minimal. +/// +internal sealed class CheckpointBatcher : IAsyncDisposable +{ + private readonly Func, CancellationToken, Task> _flushAsync; + private readonly CheckpointBatcherConfig _config; + private readonly Channel _channel; + private readonly Task _worker; + private readonly CancellationTokenSource _shutdownCts = new(); + + private string? _checkpointToken; + private Exception? _terminalError; + private int _disposed; + + public CheckpointBatcher( + string? initialCheckpointToken, + Func, CancellationToken, Task> flushAsync, + CheckpointBatcherConfig? config = null) + { + _checkpointToken = initialCheckpointToken; + _flushAsync = flushAsync; + _config = config ?? new CheckpointBatcherConfig(); + _channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + _worker = Task.Run(() => RunWorkerAsync(_shutdownCts.Token)); + } + + /// + /// The most recent checkpoint token returned by the service. Updated after + /// every successful batch flush. + /// + public string? CheckpointToken => Volatile.Read(ref _checkpointToken); + + /// + /// Queues for flushing. The returned Task completes + /// when the batch containing this update has been successfully flushed to the + /// service. If the worker has already encountered a terminal error, the + /// exception is rethrown immediately. + /// + public async Task EnqueueAsync(SdkOperationUpdate update, CancellationToken cancellationToken = default) + { + var terminal = Volatile.Read(ref _terminalError); + if (terminal != null) ExceptionDispatchInfo.Throw(terminal); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var item = new BatchItem(update, tcs); + + if (!_channel.Writer.TryWrite(item)) + { + // Writer is completed (terminal error or disposed) — surface the cause. + terminal = Volatile.Read(ref _terminalError); + if (terminal != null) ExceptionDispatchInfo.Throw(terminal); + throw new ObjectDisposedException(nameof(CheckpointBatcher)); + } + + await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Closes the channel and awaits the worker. Any items already enqueued are + /// flushed; any subsequent call throws. + /// + public async Task DrainAsync() + { + _channel.Writer.TryComplete(); + try + { + await _worker.ConfigureAwait(false); + } + catch + { + // Surfaced via _terminalError below. + } + + var terminal = Volatile.Read(ref _terminalError); + if (terminal != null) ExceptionDispatchInfo.Throw(terminal); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + + _channel.Writer.TryComplete(); + _shutdownCts.Cancel(); + try { await _worker.ConfigureAwait(false); } + catch { /* swallow on dispose */ } + _shutdownCts.Dispose(); + } + + private async Task RunWorkerAsync(CancellationToken shutdownToken) + { + // TODO: also enforce _config.MaxBatchBytes here. Today we only cap by + // operation count; an item whose serialized size pushes the batch over + // ~750 KB will be sent and rejected service-side. See CheckpointBatcherConfig. + var batch = new List(_config.MaxBatchOperations); + + try + { + while (await _channel.Reader.WaitToReadAsync(shutdownToken).ConfigureAwait(false)) + { + // Drain everything currently queued. + while (_channel.Reader.TryRead(out var item)) + { + batch.Add(item); + if (batch.Count >= _config.MaxBatchOperations) + { + await FlushBatchAsync(batch, shutdownToken).ConfigureAwait(false); + batch.Clear(); + } + } + + // Optionally wait for late arrivals to coalesce into one batch. + if (_config.FlushInterval > TimeSpan.Zero && batch.Count > 0) + { + using var windowCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); + windowCts.CancelAfter(_config.FlushInterval); + try + { + while (await _channel.Reader.WaitToReadAsync(windowCts.Token).ConfigureAwait(false)) + { + while (_channel.Reader.TryRead(out var item)) + { + batch.Add(item); + if (batch.Count >= _config.MaxBatchOperations) + { + await FlushBatchAsync(batch, shutdownToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + } + catch (OperationCanceledException) when (!shutdownToken.IsCancellationRequested) + { + // Window elapsed; fall through to flush. + } + } + + if (batch.Count > 0) + { + await FlushBatchAsync(batch, shutdownToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + catch (OperationCanceledException) when (shutdownToken.IsCancellationRequested) + { + // Disposed mid-wait; fall through to drain. + } + catch (Exception ex) + { + // FlushBatchAsync's exception path already records _terminalError and + // signals batch members. This catch covers anything else (channel, + // logic). Make sure we still propagate. + Volatile.Write(ref _terminalError, ex); + } + finally + { + // Anything left in the channel after the worker exits — fail it. + var failure = Volatile.Read(ref _terminalError) ?? new ObjectDisposedException(nameof(CheckpointBatcher)); + foreach (var leftover in batch) + leftover.Completion.TrySetException(failure); + while (_channel.Reader.TryRead(out var item)) + item.Completion.TrySetException(failure); + + _channel.Writer.TryComplete(); + } + } + + private async Task FlushBatchAsync(IReadOnlyList batch, CancellationToken cancellationToken) + { + var updates = new SdkOperationUpdate[batch.Count]; + for (int i = 0; i < batch.Count; i++) + updates[i] = batch[i].Update; + + try + { + var newToken = await _flushAsync(_checkpointToken, updates, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref _checkpointToken, newToken); + foreach (var item in batch) + item.Completion.TrySetResult(true); + } + catch (Exception ex) + { + Volatile.Write(ref _terminalError, ex); + foreach (var item in batch) + item.Completion.TrySetException(ex); + _channel.Writer.TryComplete(); + // No rethrow: the worker loop exits via the completed channel and + // RunWorkerAsync's finally handles any leftovers. + } + } + + private readonly record struct BatchItem(SdkOperationUpdate Update, TaskCompletionSource Completion); +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcherConfig.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcherConfig.cs new file mode 100644 index 000000000..a5e60b98e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/CheckpointBatcherConfig.cs @@ -0,0 +1,35 @@ +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Tunables for . +/// +internal sealed class CheckpointBatcherConfig +{ + /// + /// How long the worker waits for additional items to coalesce into a single + /// batch before flushing. Default = flush as soon + /// as the queue drains. Increase to reduce API calls when many checkpoints + /// are emitted concurrently (e.g. parallel branches, future Map operation). + /// + public TimeSpan FlushInterval { get; init; } = TimeSpan.Zero; + + /// + /// Maximum operations per batch. Service-side limit is 200. + /// + public int MaxBatchOperations { get; init; } = 200; + + /// + /// Maximum batch size in bytes. Service-side limit is ~750 KB. + /// + /// + /// TODO: not enforced today. The worker only checks ; + /// a single oversized item (or a batch whose serialized size exceeds 750 KB) + /// will be sent to the service and rejected there. Java/JS/Python all + /// pre-flight this on the in-flight batch and split before the next add. + /// Wire this in alongside the async-flush operations (Map / Parallel / + /// child-context) since those are the scenarios that can actually fill a + /// batch — today every batch is 1 item with + /// = Zero, so the gap is latent. + /// + internal int MaxBatchBytes { get; init; } = 750 * 1024; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs new file mode 100644 index 000000000..e7734abf9 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs @@ -0,0 +1,69 @@ +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Abstract base for durable operations (Step, Wait, ...). Subclasses implement +/// (no prior checkpoint) and +/// (some checkpoint exists); the base handles lookup and dispatch. +/// +/// The operation's result type. +internal abstract class DurableOperation +{ + protected readonly ExecutionState State; + protected readonly TerminationManager Termination; + protected readonly string OperationId; + protected readonly string? Name; + protected readonly string DurableExecutionArn; + protected readonly CheckpointBatcher? Batcher; + + protected DurableOperation( + string operationId, + string? name, + ExecutionState state, + TerminationManager termination, + string durableExecutionArn, + CheckpointBatcher? batcher = null) + { + OperationId = operationId; + Name = name; + State = state; + Termination = termination; + DurableExecutionArn = durableExecutionArn; + Batcher = batcher; + } + + /// The wire-format operation type (e.g. "STEP", "WAIT"). + protected abstract string OperationType { get; } + + /// + /// Looks up any prior checkpoint for this op and dispatches to + /// (none) or (some). + /// + public Task ExecuteAsync(CancellationToken cancellationToken) + { + State.ValidateReplayConsistency(OperationId, OperationType, Name); + + var existing = State.GetOperation(OperationId); + return existing == null + ? StartAsync(cancellationToken) + : ReplayAsync(existing, cancellationToken); + } + + /// First-time execution path: no prior checkpoint exists. + protected abstract Task StartAsync(CancellationToken cancellationToken); + + /// + /// Replay path: a checkpoint from a prior invocation exists. Subclasses + /// switch on . + /// against constants. + /// + protected abstract Task ReplayAsync(Operation existing, CancellationToken cancellationToken); + + /// + /// Enqueues an outbound checkpoint and awaits its batch flush. No-op when + /// no batcher is wired (e.g. unit tests that don't exercise flushing). + /// + protected Task EnqueueAsync(SdkOperationUpdate update, CancellationToken cancellationToken = default) + => Batcher?.EnqueueAsync(update, cancellationToken) ?? Task.CompletedTask; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs new file mode 100644 index 000000000..5ee690be0 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs @@ -0,0 +1,93 @@ +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Replay state of the current invocation. +/// +internal enum ExecutionMode +{ + /// Re-deriving prior operations from checkpointed state. + Replay, + /// Executing fresh code that hasn't been checkpointed before. + Execution +} + +/// +/// In-memory store of the operations replayed from . +/// Read-only after load (apart from ); outbound +/// checkpoints are owned by . +/// +internal sealed class ExecutionState +{ + private readonly Dictionary _operations = new(); + + public ExecutionMode Mode { get; private set; } = ExecutionMode.Replay; + + public int CheckpointedOperationCount => _operations.Count; + + public void LoadFromCheckpoint(InitialExecutionState? initialState) + { + if (initialState?.Operations == null) + { + Mode = ExecutionMode.Execution; + return; + } + + AddOperations(initialState.Operations); + + if (_operations.Count == 0) + { + Mode = ExecutionMode.Execution; + } + } + + public void AddOperations(IEnumerable operations) + { + foreach (var op in operations) + { + if (op.Id == null) continue; + _operations[op.Id] = op; + } + } + + /// + /// Returns the checkpointed record for , or null + /// if none. Callers should switch on against + /// constants to decide replay behavior. + /// + public Operation? GetOperation(string operationId) + { + _operations.TryGetValue(operationId, out var op); + return op; + } + + public void ValidateReplayConsistency(string operationId, string expectedType, string? expectedName) + { + if (Mode != ExecutionMode.Replay) return; + + if (!_operations.TryGetValue(operationId, out var op)) return; + + if (op.Type != null && op.Type != expectedType) + { + throw new NonDeterministicExecutionException( + $"Non-deterministic execution detected for operation '{operationId}': " + + $"expected type '{expectedType}' but found '{op.Type}' from a previous invocation. " + + $"Code must not change the order or type of durable operations between deployments."); + } + + if (expectedName != null && op.Name != null && op.Name != expectedName) + { + throw new NonDeterministicExecutionException( + $"Non-deterministic execution detected for operation '{operationId}': " + + $"expected name '{expectedName}' but found '{op.Name}' from a previous invocation. " + + $"Code must not change the order or type of durable operations between deployments."); + } + } + + public bool HasOperation(string operationId) => _operations.ContainsKey(operationId); + + /// + /// Transitions to . Called by an operation + /// that's about to run fresh (not-yet-checkpointed) code. Idempotent. + /// + public void EnterExecutionMode() => Mode = ExecutionMode.Execution; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs new file mode 100644 index 000000000..473c7a3b2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// One operation in the durable execution service's invocation envelope. +/// Property names mirror the wire format exactly so System.Text.Json can +/// populate this type declaratively. Internal — consumed by ExecutionState +/// and DurableContext during replay; never exposed on a public surface. +/// +internal sealed class Operation +{ + [JsonPropertyName("Id")] + public string? Id { get; set; } + + [JsonPropertyName("Type")] + public string? Type { get; set; } + + [JsonPropertyName("Status")] + public string? Status { get; set; } + + [JsonPropertyName("Name")] + public string? Name { get; set; } + + [JsonPropertyName("ParentId")] + public string? ParentId { get; set; } + + [JsonPropertyName("SubType")] + public string? SubType { get; set; } + + [JsonPropertyName("StartTimestamp")] + public long? StartTimestamp { get; set; } + + [JsonPropertyName("EndTimestamp")] + public long? EndTimestamp { get; set; } + + [JsonPropertyName("StepDetails")] + public StepDetails? StepDetails { get; set; } + + [JsonPropertyName("WaitDetails")] + public WaitDetails? WaitDetails { get; set; } + + [JsonPropertyName("ExecutionDetails")] + public ExecutionDetails? ExecutionDetails { get; set; } + + [JsonPropertyName("CallbackDetails")] + public CallbackDetails? CallbackDetails { get; set; } + + [JsonPropertyName("ChainedInvokeDetails")] + public ChainedInvokeDetails? ChainedInvokeDetails { get; set; } + + [JsonPropertyName("ContextDetails")] + public ContextDetails? ContextDetails { get; set; } +} + +internal sealed class StepDetails +{ + [JsonPropertyName("Result")] + public string? Result { get; set; } + + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } + + [JsonPropertyName("Attempt")] + public int? Attempt { get; set; } + + [JsonPropertyName("NextAttemptTimestamp")] + public long? NextAttemptTimestamp { get; set; } +} + +internal sealed class WaitDetails +{ + [JsonPropertyName("ScheduledEndTimestamp")] + public long? ScheduledEndTimestamp { get; set; } +} + +internal sealed class ExecutionDetails +{ + [JsonPropertyName("InputPayload")] + public string? InputPayload { get; set; } +} + +internal sealed class CallbackDetails +{ + [JsonPropertyName("CallbackId")] + public string? CallbackId { get; set; } + + [JsonPropertyName("Result")] + public string? Result { get; set; } + + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +internal sealed class ChainedInvokeDetails +{ + [JsonPropertyName("Result")] + public string? Result { get; set; } + + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +internal sealed class ContextDetails +{ + [JsonPropertyName("Result")] + public string? Result { get; set; } + + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +/// +/// Wire-format string constants. +/// Plural name avoids collision with Amazon.Lambda.OperationType. +/// +internal static class OperationTypes +{ + public const string Step = "STEP"; + public const string Wait = "WAIT"; + public const string Callback = "CALLBACK"; + public const string ChainedInvoke = "CHAINED_INVOKE"; + public const string Context = "CONTEXT"; + public const string Execution = "EXECUTION"; +} + +/// +/// Wire-format string constants. +/// Plural name avoids collision with Amazon.Lambda.OperationStatus. +/// +internal static class OperationStatuses +{ + public const string Started = "STARTED"; + public const string Succeeded = "SUCCEEDED"; + public const string Failed = "FAILED"; + public const string Pending = "PENDING"; + public const string Cancelled = "CANCELLED"; + public const string Ready = "READY"; + public const string Stopped = "STOPPED"; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs new file mode 100644 index 000000000..fef9cab19 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Generates deterministic operation IDs for durable operations. Each call +/// increments an internal counter and SHA-256 hashes "<parentId>-<counter>" +/// (or just "<counter>" at the root). Hashing matches the wire format +/// used by the Java/JS/Python SDKs so the same workflow position produces a +/// stable, opaque ID across replays — and the human-readable step name is +/// carried separately on OperationUpdate.Name, so renaming a step does +/// not break replay correlation. +/// +internal sealed class OperationIdGenerator +{ + private int _counter; + private readonly string _prefix; + + /// + /// Creates a root-level generator. + /// + public OperationIdGenerator() + : this(parentId: null) + { + } + + /// + /// Creates a child generator scoped under a parent operation. The parent + /// ID (already hashed) becomes part of the prefix, so child IDs are + /// hash("<parentHash>-1"), hash("<parentHash>-2"), etc. + /// + public OperationIdGenerator(string? parentId) + { + _counter = 0; + ParentId = parentId; + _prefix = parentId != null ? parentId + "-" : string.Empty; + } + + /// + /// Gets the parent operation ID, if any. + /// + public string? ParentId { get; } + + /// + /// Generates the next operation ID. The counter is pre-incremented so the + /// first ID is hash("1"), matching the reference SDKs. + /// + public string NextId() + { + var counter = ++_counter; + return HashOperationId(_prefix + counter.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// SHA-256 hashes and returns a 64-char lowercase + /// hex digest. Public so tests and child-context construction can reproduce + /// the same hashing logic. + /// + public static string HashOperationId(string rawId) + { + var bytes = Encoding.UTF8.GetBytes(rawId); + Span hash = stackalloc byte[32]; +#if NET8_0_OR_GREATER + SHA256.HashData(bytes, hash); +#else + using var sha = SHA256.Create(); + var computed = sha.ComputeHash(bytes); + computed.CopyTo(hash); +#endif + return ToHex(hash); + } + + private static string ToHex(ReadOnlySpan bytes) + { + const string Hex = "0123456789abcdef"; + var chars = new char[bytes.Length * 2]; + for (int i = 0; i < bytes.Length; i++) + { + chars[i * 2] = Hex[bytes[i] >> 4]; + chars[i * 2 + 1] = Hex[bytes[i] & 0xF]; + } + return new string(chars); + } + + /// + /// Creates a child generator scoped under an operation ID from this generator. + /// + public OperationIdGenerator CreateChild(string operationId) + { + return new OperationIdGenerator(operationId); + } + + /// + /// Resets the counter (used for testing only). + /// + internal void Reset() + { + _counter = 0; + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs new file mode 100644 index 000000000..f7a3d0572 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Default backed by reflection-based +/// . Constructed only by the reflection-overload +/// path of DurableContext.StepAsync; the constructor carries +/// so AOT/trimmed deployments +/// see the warning at the call site that picks this overload. +/// +internal sealed class ReflectionJsonCheckpointSerializer : ICheckpointSerializer +{ + [RequiresUnreferencedCode("Uses reflection-based JsonSerializer; not AOT-safe.")] + [RequiresDynamicCode("Uses reflection-based JsonSerializer; not AOT-safe.")] + public ReflectionJsonCheckpointSerializer() { } + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] + public string Serialize(T value, SerializationContext context) + { + return JsonSerializer.Serialize(value); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] + public T Deserialize(string data, SerializationContext context) + { + return JsonSerializer.Deserialize(data)!; + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs new file mode 100644 index 000000000..d5084229b --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Logging; +using SdkErrorObject = Amazon.Lambda.Model.ErrorObject; +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Durable step operation. Runs the user's function once across the lifetime +/// of a durable execution, persisting its result so subsequent invocations +/// replay the cached value without re-executing. +/// +/// +/// Replay semantics — example: await ctx.StepAsync(ChargeCard, "charge") +/// +/// Fresh: no prior state → run func → emit SUCCEED → return result. +/// Replay (SUCCEEDED): return cached result; func is NOT re-executed. +/// Replay (FAILED): re-throw the recorded exception. +/// +/// Serialization is delegated to the supplied ; +/// the AOT-safe overloads of IDurableContext.StepAsync wire in a +/// user-supplied serializer, while the reflection overloads inject +/// . +/// +internal sealed class StepOperation : DurableOperation +{ + private readonly Func> _func; + private readonly StepConfig? _config; + private readonly ICheckpointSerializer _serializer; + private readonly ILogger _logger; + + public StepOperation( + string operationId, + string? name, + Func> func, + StepConfig? config, + ICheckpointSerializer serializer, + ILogger logger, + ExecutionState state, + TerminationManager termination, + string durableExecutionArn, + CheckpointBatcher? batcher = null) + : base(operationId, name, state, termination, durableExecutionArn, batcher) + { + _func = func; + _config = config; + _serializer = serializer; + _logger = logger; + } + + protected override string OperationType => OperationTypes.Step; + + protected override Task StartAsync(CancellationToken cancellationToken) + { + State.EnterExecutionMode(); + return ExecuteFunc(cancellationToken); + } + + protected override Task ReplayAsync(Operation existing, CancellationToken cancellationToken) + { + switch (existing.Status) + { + case OperationStatuses.Succeeded: + // Side-effecting code runs at most once: replay returns the + // cached result without invoking func. + return Task.FromResult(DeserializeResult(existing.StepDetails?.Result)); + + case OperationStatuses.Failed: + // Retries were exhausted or never configured — re-throw so the + // user's catch-block flow matches the original execution. + throw CreateStepException(existing); + + default: + // STARTED/READY/PENDING from a prior invocation — no retry logic + // in this commit, so fall through and execute fresh. (Future work + // on retries will replace this default with explicit arms.) + State.EnterExecutionMode(); + return ExecuteFunc(cancellationToken); + } + } + + private async Task ExecuteFunc(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // TODO: emit a STEP_STARTED checkpoint (action = "START") here when retries + // and/or AtMostOncePerRetry semantics land. AtMostOncePerRetry needs the + // START to be sync-flushed before user code runs (so replay can detect + // "we already attempted this and must not re-run"). AtLeastOncePerRetry + // wants it fire-and-forget for telemetry (attempt timing, retry count in + // history). Both require the async-flush overload in CheckpointBatcher + // (see TODO in CheckpointBatcher.cs). Today neither feature is wired up, + // so the START is intentionally omitted — SUCCEED alone is sufficient + // for replay correctness in the AtLeastOncePerRetry-only world this PR + // ships. Java SDK precedent: StepOperation.checkpointStarted(). + try + { + var stepContext = new StepContext(OperationId, attemptNumber: 1, _logger); + var result = await _func(stepContext); + + await EnqueueAsync(new SdkOperationUpdate + { + Id = OperationId, + Type = OperationTypes.Step, + Action = "SUCCEED", + SubType = "Step", + Name = Name, + Payload = SerializeResult(result) + }, cancellationToken); + + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // No retry logic in this commit: any thrown exception becomes a + // FAIL checkpoint and is re-thrown as a StepException. On replay, + // the FAILED branch above will re-throw without re-executing. + await EnqueueAsync(new SdkOperationUpdate + { + Id = OperationId, + Type = OperationTypes.Step, + Action = "FAIL", + SubType = "Step", + Name = Name, + Error = ToSdkError(ex) + }, cancellationToken); + + throw new StepException(ex.Message, ex) + { + ErrorType = ex.GetType().FullName + }; + } + } + + private T DeserializeResult(string? serialized) + { + if (serialized == null) return default!; + return _serializer.Deserialize(serialized, new SerializationContext(OperationId, DurableExecutionArn)); + } + + private string SerializeResult(T value) + => _serializer.Serialize(value, new SerializationContext(OperationId, DurableExecutionArn)); + + private static StepException CreateStepException(Operation failedOp) + { + var err = failedOp.StepDetails?.Error; + return new StepException(err?.ErrorMessage ?? "Step failed") + { + ErrorType = err?.ErrorType, + ErrorData = err?.ErrorData, + OriginalStackTrace = err?.StackTrace + }; + } + + private static SdkErrorObject ToSdkError(Exception ex) => new() + { + ErrorType = ex.GetType().FullName, + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace?.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).ToList() + }; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/TerminationManager.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/TerminationManager.cs new file mode 100644 index 000000000..1350c3d70 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/TerminationManager.cs @@ -0,0 +1,77 @@ +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// The reason the execution was terminated. +/// +internal enum TerminationReason +{ + WaitScheduled, + CallbackPending, + InvokePending, + CheckpointFailed +} + +/// +/// The result of a termination signal. +/// +internal sealed class TerminationResult +{ + public required TerminationReason Reason { get; init; } + public string? Message { get; init; } + public Exception? Exception { get; init; } +} + +/// +/// Manages the suspension signal for durable execution. +/// Uses a TaskCompletionSource that resolves when the function should suspend. +/// Only the first Terminate() call wins; subsequent calls are ignored. +/// +internal sealed class TerminationManager +{ + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _terminated; + + /// + /// A Task that resolves when Terminate() is called. Used in Task.WhenAny + /// to race against user code. + /// + public Task TerminationTask => _tcs.Task; + + /// + /// Whether Terminate() has been called. + /// + public bool IsTerminated => Volatile.Read(ref _terminated) == 1; + + /// + /// Signals that the execution should suspend. Thread-safe; only the first + /// call has effect. + /// + /// true if this call triggered termination, false if already terminated. + public bool Terminate(TerminationReason reason, string? message = null, Exception? exception = null) + { + if (Interlocked.CompareExchange(ref _terminated, 1, 0) != 0) + return false; + + _tcs.TrySetResult(new TerminationResult + { + Reason = reason, + Message = message, + Exception = exception + }); + + return true; + } + + /// + /// Trips the termination signal and returns a Task that never completes. + /// This is the standard suspension idiom: the caller awaits the returned + /// Task, and 's Task.WhenAny + /// race picks up instead, returning Pending + /// to the service. The returned Task is abandoned and GC'd. + /// + public Task SuspendAndAwait(TerminationReason reason, string? message = null, Exception? exception = null) + { + Terminate(reason, message, exception); + return new TaskCompletionSource().Task; + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs new file mode 100644 index 000000000..9610ca5f4 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// Converts between UPPER_SNAKE_CASE wire format (e.g., CHAINED_INVOKE) +/// and PascalCase enum values (e.g., ChainedInvoke). +/// +/// +public sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum +{ + /// + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + var value = reader.GetString(); + if (value == null) + return default; + + // Convert UPPER_SNAKE_CASE to PascalCase for enum lookup + var pascalCase = SnakeToPascal(value); + + if (Enum.TryParse(pascalCase, ignoreCase: true, out var result)) + return result; + + // Fallback: try direct case-insensitive parse of the raw value + if (Enum.TryParse(value, ignoreCase: true, out result)) + return result; + + throw new JsonException($"Unable to parse '{value}' as {typeof(T).Name}."); + } + + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(PascalToSnake(value.ToString())); + } + + private static string SnakeToPascal(string snake) + { + var parts = snake.Split('_'); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length > 0) + parts[i] = char.ToUpper(parts[i][0]) + parts[i][1..].ToLower(); + } + return string.Join("", parts); + } + + private static string PascalToSnake(string pascal) + { + var result = new System.Text.StringBuilder(); + for (int i = 0; i < pascal.Length; i++) + { + if (i > 0 && char.IsUpper(pascal[i])) + result.Append('_'); + result.Append(char.ToUpper(pascal[i])); + } + return result.ToString(); + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs new file mode 100644 index 000000000..4fb069bf3 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs @@ -0,0 +1,93 @@ +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; +using SdkWaitOptions = Amazon.Lambda.Model.WaitOptions; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Durable wait operation. Suspends the workflow for a given duration without +/// consuming compute time; the service schedules a timer and re-invokes Lambda +/// when it fires. +/// +/// +/// Replay semantics — example: await ctx.WaitAsync(TimeSpan.FromHours(1)) +/// +/// Fresh: emit WAIT START → flush → suspend → service schedules timer. +/// Replay (SUCCEEDED): timer fired, return CompletedTask. +/// Replay (STARTED/PENDING): timer still ticking → re-suspend (or +/// short-circuit if the deadline already elapsed but SUCCEEDED hasn't +/// been stamped yet). +/// +/// See for the +/// suspension mechanics (Task.WhenAny race against TerminationManager). +/// +internal sealed class WaitOperation : DurableOperation +{ + private readonly int _waitSeconds; + + public WaitOperation( + string operationId, + string? name, + int waitSeconds, + ExecutionState state, + TerminationManager termination, + string durableExecutionArn, + CheckpointBatcher? batcher = null) + : base(operationId, name, state, termination, durableExecutionArn, batcher) + { + _waitSeconds = waitSeconds; + } + + protected override string OperationType => OperationTypes.Wait; + + protected override async Task StartAsync(CancellationToken cancellationToken) + { + State.EnterExecutionMode(); + + // Sync-flush WAIT START before suspending — the service can't schedule + // a timer for a checkpoint it hasn't received. + await EnqueueAsync(new SdkOperationUpdate + { + Id = OperationId, + Type = OperationTypes.Wait, + Action = "START", + SubType = "Wait", + Name = Name, + WaitOptions = new SdkWaitOptions { WaitSeconds = _waitSeconds } + }, cancellationToken); + + return await Termination.SuspendAndAwait( + TerminationReason.WaitScheduled, $"wait:{Name ?? OperationId}"); + } + + protected override Task ReplayAsync(Operation existing, CancellationToken cancellationToken) + { + switch (existing.Status) + { + case OperationStatuses.Succeeded: + // Common post-timer case: service stamped the wait as SUCCEEDED + // and re-invoked Lambda. Workflow proceeds to the next step. + return Task.FromResult(null); + + case OperationStatuses.Started: + case OperationStatuses.Pending: + // Service hasn't marked the wait complete yet. Either the timer + // is still ticking, or the deadline elapsed but SUCCEEDED hasn't + // been stamped yet — treat elapsed deadlines as "done" to avoid + // a pointless extra round-trip. + var expiresAtMs = existing.WaitDetails?.ScheduledEndTimestamp; + if (expiresAtMs is { } ts && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() >= ts) + { + return Task.FromResult(null); + } + + // Timer still ticking — re-suspend without re-checkpointing. + // The original WAIT START is still authoritative. + return Termination.SuspendAndAwait( + TerminationReason.WaitScheduled, $"wait:{Name ?? OperationId}"); + + default: + throw new NonDeterministicExecutionException( + $"Wait operation '{Name ?? OperationId}' has unexpected status '{existing.Status}' on replay."); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs new file mode 100644 index 000000000..35bc32ecd --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution.Internal; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// The service envelope input for a durable execution invocation. +/// This is what Lambda receives from the durable execution service. +/// +public sealed class DurableExecutionInvocationInput +{ + /// + /// The unique ARN identifying this durable execution. + /// + [JsonPropertyName("DurableExecutionArn")] + public required string DurableExecutionArn { get; set; } + + /// + /// Token for optimistic concurrency on checkpoint operations. + /// + [JsonPropertyName("CheckpointToken")] + public string? CheckpointToken { get; set; } + + /// + /// Previously checkpointed operation state for replay. Internal — consumed + /// only by DurableFunction.WrapAsync for replay correlation; user code + /// should never read or modify this. Marked + /// so System.Text.Json populates it during deserialization despite being internal + /// (framework needs it, but it's not part of the public API contract). + /// + [JsonPropertyName("InitialExecutionState")] + [JsonInclude] + internal InitialExecutionState? InitialExecutionState { get; set; } +} + +/// +/// The previously checkpointed execution state provided on replay invocations. +/// +internal sealed class InitialExecutionState +{ + /// + /// The list of operations from prior invocations. + /// + [JsonPropertyName("Operations")] + public IReadOnlyList? Operations { get; set; } + + /// + /// If present, indicates that more operations are available. Use this value + /// with GetDurableExecutionState to fetch the next page. + /// + [JsonPropertyName("NextMarker")] + public string? NextMarker { get; set; } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs new file mode 100644 index 000000000..602f0b245 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// The service envelope output returned by a durable execution invocation. +/// +public sealed class DurableExecutionInvocationOutput +{ + /// + /// The terminal status of this invocation. + /// + [JsonPropertyName("Status")] + [JsonConverter(typeof(UpperSnakeCaseEnumConverter))] + public required InvocationStatus Status { get; set; } + + /// + /// The serialized result (only present when Status is Succeeded). + /// + [JsonPropertyName("Result")] + public string? Result { get; set; } + + /// + /// Error details (only present when Status is Failed). + /// + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs new file mode 100644 index 000000000..20acac47f --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// Serializable error representation stored in checkpoint state. +/// +public sealed class ErrorObject +{ + /// + /// The fully-qualified exception type name. + /// + [JsonPropertyName("ErrorType")] + public string? ErrorType { get; set; } + + /// + /// The exception message. + /// + [JsonPropertyName("ErrorMessage")] + public string? ErrorMessage { get; set; } + + /// + /// Stack trace frames. + /// + [JsonPropertyName("StackTrace")] + public IReadOnlyList? StackTrace { get; set; } + + /// + /// Additional serialized error data. + /// + [JsonPropertyName("ErrorData")] + public string? ErrorData { get; set; } + + /// + /// Creates an ErrorObject from an exception. + /// + public static ErrorObject FromException(Exception exception) + { + return new ErrorObject + { + ErrorType = exception.GetType().FullName, + ErrorMessage = exception.Message, + StackTrace = exception.StackTrace?.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + }; + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs new file mode 100644 index 000000000..709341760 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs @@ -0,0 +1,108 @@ +using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.Model; +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; +using SdkOperation = Amazon.Lambda.Model.Operation; + +namespace Amazon.Lambda.DurableExecution.Services; + +/// +/// Calls the real AWS Lambda Durable Execution APIs via the AWSSDK.Lambda client. +/// +internal sealed class LambdaDurableServiceClient +{ + private readonly IAmazonLambda _lambdaClient; + + public LambdaDurableServiceClient(IAmazonLambda lambdaClient) + { + _lambdaClient = lambdaClient; + } + + /// + /// Flushes pending checkpoint operations to the durable execution service. + /// + public async Task CheckpointAsync( + string durableExecutionArn, + string? checkpointToken, + IReadOnlyList pendingOperations, + CancellationToken cancellationToken = default) + { + if (pendingOperations.Count == 0) + return checkpointToken; + + var request = new CheckpointDurableExecutionRequest + { + DurableExecutionArn = durableExecutionArn, + CheckpointToken = checkpointToken ?? "", + Updates = pendingOperations is List list ? list : pendingOperations.ToList() + }; + + var response = await _lambdaClient.CheckpointDurableExecutionAsync(request, cancellationToken); + return response.CheckpointToken; + } + + /// + /// Fetches additional pages of execution state when the initial state is paginated. + /// + public async Task<(List Operations, string? NextMarker)> GetExecutionStateAsync( + string durableExecutionArn, + string? checkpointToken, + string marker, + CancellationToken cancellationToken = default) + { + var request = new GetDurableExecutionStateRequest + { + DurableExecutionArn = durableExecutionArn, + CheckpointToken = checkpointToken ?? "", + Marker = marker + }; + + var response = await _lambdaClient.GetDurableExecutionStateAsync(request, cancellationToken); + + var operations = new List(); + if (response.Operations != null) + { + foreach (var sdkOp in response.Operations) + { + operations.Add(MapFromSdkOperation(sdkOp)); + } + } + + return (operations, response.NextMarker); + } + + private static Internal.Operation MapFromSdkOperation(SdkOperation sdkOp) + { + return new Internal.Operation + { + Id = sdkOp.Id, + Type = sdkOp.Type, + Status = sdkOp.Status, + Name = sdkOp.Name, + ParentId = sdkOp.ParentId, + SubType = sdkOp.SubType, + StepDetails = sdkOp.StepDetails != null ? new Internal.StepDetails + { + Result = sdkOp.StepDetails.Result, + Error = sdkOp.StepDetails.Error != null ? new ErrorObject + { + ErrorType = sdkOp.StepDetails.Error.ErrorType, + ErrorMessage = sdkOp.StepDetails.Error.ErrorMessage + } : null, + Attempt = sdkOp.StepDetails.Attempt, + NextAttemptTimestamp = sdkOp.StepDetails.NextAttemptTimestamp.HasValue + ? new DateTimeOffset(sdkOp.StepDetails.NextAttemptTimestamp.Value, TimeSpan.Zero).ToUnixTimeMilliseconds() + : null + } : null, + WaitDetails = sdkOp.WaitDetails != null ? new Internal.WaitDetails + { + ScheduledEndTimestamp = sdkOp.WaitDetails.ScheduledEndTimestamp.HasValue + ? new DateTimeOffset(sdkOp.WaitDetails.ScheduledEndTimestamp.Value, TimeSpan.Zero).ToUnixTimeMilliseconds() + : null + } : null, + ExecutionDetails = sdkOp.ExecutionDetails != null ? new Internal.ExecutionDetails + { + InputPayload = sdkOp.ExecutionDetails.InputPayload + } : null + }; + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Amazon.Lambda.DurableExecution.AotPublishTest.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Amazon.Lambda.DurableExecution.AotPublishTest.csproj new file mode 100644 index 000000000..ec4d0ffd0 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Amazon.Lambda.DurableExecution.AotPublishTest.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + enable + true + true + full + false + true + IL2026,IL2067,IL2075,IL3050 + false + + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs new file mode 100644 index 000000000..af84aca8c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace Amazon.Lambda.DurableExecution.AotPublishTest; + +/// +/// AOT publish smoke check. This program must publish under NativeAOT with +/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). It uses +/// the JsonSerializerContext overload of WrapAsync. +/// +public class Program +{ + public static async Task Main() + { + var serializer = new SourceGeneratorLambdaJsonSerializer(); + Func> handler = HandlerAsync; + await LambdaBootstrapBuilder + .Create(handler, serializer) + .Build() + .RunAsync(); + } + + public static Task HandlerAsync( + DurableExecutionInvocationInput input, ILambdaContext context) => + DurableFunction.WrapAsync( + WorkflowAsync, input, context, AotJsonContext.Default); + + private static async Task WorkflowAsync(OrderEvent input, IDurableContext context) + { + var validation = await context.StepAsync( + async (_) => + { + await Task.CompletedTask; + return new ValidationResult { IsValid = true }; + }, + new ValidationResultSerializer(), + name: "validate"); + + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "delay"); + + return new OrderResult { Status = validation.IsValid ? "approved" : "rejected", OrderId = input.OrderId }; + } + + private sealed class ValidationResultSerializer : ICheckpointSerializer + { + public string Serialize(ValidationResult value, SerializationContext ctx) => + System.Text.Json.JsonSerializer.Serialize(value, AotJsonContext.Default.ValidationResult); + + public ValidationResult Deserialize(string data, SerializationContext ctx) => + System.Text.Json.JsonSerializer.Deserialize(data, AotJsonContext.Default.ValidationResult) + ?? new ValidationResult(); + } + + public class OrderEvent + { + public string? OrderId { get; set; } + } + + public class OrderResult + { + public string? Status { get; set; } + public string? OrderId { get; set; } + } + + public class ValidationResult + { + public bool IsValid { get; set; } + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(Program.OrderEvent))] +[JsonSerializable(typeof(Program.OrderResult))] +[JsonSerializable(typeof(Program.ValidationResult))] +public partial class AotJsonContext : JsonSerializerContext +{ +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj new file mode 100644 index 000000000..0ef2e561d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj @@ -0,0 +1,43 @@ + + + + + + + $(DefaultPackageTargets) + enable + enable + false + true + $(NoWarn);NU1903;CS1591 + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs new file mode 100644 index 000000000..8b5bb2e1b --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs @@ -0,0 +1,468 @@ +using System.Text; +using System.Text.Json; +using Amazon; +using Amazon.ECR; +using Amazon.ECR.Model; +using Amazon.IdentityManagement; +using Amazon.IdentityManagement.Model; +using Amazon.Lambda; +using Amazon.Lambda.Model; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +/// +/// Builds, deploys, and invokes a single durable Lambda function for an integration test. +/// Manages the full lifecycle: IAM role, ECR repo, Docker image, Lambda function. +/// All resources are torn down on DisposeAsync. +/// +internal sealed class DurableFunctionDeployment : IAsyncDisposable +{ + private readonly ITestOutputHelper _output; + private readonly IAmazonLambda _lambdaClient; + private readonly IAmazonECR _ecrClient; + private readonly IAmazonIdentityManagementService _iamClient; + + private readonly string _functionName; + private readonly string _repoName; + private readonly string _roleName; + private string? _roleArn; + private string? _imageUri; + private bool _functionCreated; + private bool _ecrRepoCreated; + + public string FunctionName => _functionName; + public IAmazonLambda LambdaClient => _lambdaClient; + + private DurableFunctionDeployment(ITestOutputHelper output, string suffix) + { + _output = output; + _lambdaClient = new AmazonLambdaClient(RegionEndpoint.USEast1); + _ecrClient = new AmazonECRClient(RegionEndpoint.USEast1); + _iamClient = new AmazonIdentityManagementServiceClient(RegionEndpoint.USEast1); + + // Truncate the GUID (not the suffix) so CloudTrail entries stay readable. + // Keep the GUID short enough that the total stays well under 40 chars even for long suffixes. + static string ShortId() => Guid.NewGuid().ToString("N")[..Math.Min(8, 32)]; + _functionName = $"durable-integ-{suffix}-{ShortId()}"; + _repoName = $"durable-integ-{suffix}-{ShortId()}"; + _roleName = $"durable-integ-{suffix}-{ShortId()}"; + } + + public static async Task CreateAsync( + string testFunctionDir, + string scenarioSuffix, + ITestOutputHelper output) + { + var deployment = new DurableFunctionDeployment(output, scenarioSuffix); + try + { + await deployment.InitializeAsync(testFunctionDir); + } + catch + { + // Tear down anything that did get created (IAM role, ECR repo) so we + // don't leak resources when init fails part-way through. + await deployment.DisposeAsync(); + throw; + } + return deployment; + } + + private async Task InitializeAsync(string testFunctionDir) + { + // 1. Create IAM role + _output.WriteLine($"Creating IAM role: {_roleName}"); + var assumeRolePolicy = """ + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + } + """; + + var createRoleResponse = await _iamClient.CreateRoleAsync(new CreateRoleRequest + { + RoleName = _roleName, + AssumeRolePolicyDocument = assumeRolePolicy + }); + _roleArn = createRoleResponse.Role.Arn; + + await _iamClient.AttachRolePolicyAsync(new AttachRolePolicyRequest + { + RoleName = _roleName, + PolicyArn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + }); + + await _iamClient.AttachRolePolicyAsync(new AttachRolePolicyRequest + { + RoleName = _roleName, + PolicyArn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy" + }); + + // Wait for IAM propagation + await Task.Delay(TimeSpan.FromSeconds(10)); + + // 2. Create ECR repository + _output.WriteLine($"Creating ECR repository: {_repoName}"); + var createRepoResponse = await _ecrClient.CreateRepositoryAsync(new CreateRepositoryRequest + { + RepositoryName = _repoName + }); + _ecrRepoCreated = true; + var repositoryUri = createRepoResponse.Repository.RepositoryUri; + + // 3. Build and push Docker image + _output.WriteLine($"Building and pushing Docker image from {testFunctionDir}..."); + _imageUri = await BuildAndPushImage(testFunctionDir, repositoryUri); + _output.WriteLine($"Image pushed: {_imageUri}"); + + // 4. Create Lambda function + _output.WriteLine($"Creating Lambda function: {_functionName}"); + await _lambdaClient.CreateFunctionAsync(new CreateFunctionRequest + { + FunctionName = _functionName, + PackageType = PackageType.Image, + Role = _roleArn, + Code = new FunctionCode { ImageUri = _imageUri }, + Timeout = 30, + MemorySize = 256, + DurableConfig = new DurableConfig { ExecutionTimeout = 60 } + }); + _functionCreated = true; + + _output.WriteLine("Waiting for function to become Active..."); + await WaitForFunctionActive(); + } + + public async Task<(InvokeResponse Response, string ExecutionName)> InvokeAsync(string payload, string? executionName = null) + { + var name = executionName ?? $"integ-test-{Guid.NewGuid():N}"; + var response = await _lambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = _functionName, + Qualifier = "$LATEST", + Payload = payload, + DurableExecutionName = name + }); + return (response, name); + } + + /// + /// Polls ListDurableExecutionsByFunction until an execution with the given name appears. + /// Useful when the synchronous Invoke response gives no ARN (e.g., failed workflows return null). + /// + public async Task FindDurableExecutionArnByNameAsync(string executionName, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + var attempt = 0; + _output.WriteLine($"[FindArn] Starting search for execution name '{executionName}' on function '{_functionName}' (timeout: {timeout.TotalSeconds}s)"); + + while (DateTime.UtcNow < deadline) + { + attempt++; + try + { + var resp = await _lambdaClient.ListDurableExecutionsByFunctionAsync( + new ListDurableExecutionsByFunctionRequest + { + FunctionName = _functionName, + DurableExecutionName = executionName // server-side exact match + }); + + var count = resp.DurableExecutions?.Count ?? 0; + _output.WriteLine($"[FindArn] attempt {attempt}: List returned {count} executions"); + + if (count > 0) + { + foreach (var e in resp.DurableExecutions!) + { + _output.WriteLine($"[FindArn] - name='{e.DurableExecutionName}' status={e.Status} arn={e.DurableExecutionArn}"); + } + var match = resp.DurableExecutions.FirstOrDefault(e => e.DurableExecutionName == executionName); + if (match != null) + { + _output.WriteLine($"[FindArn] matched on attempt {attempt}"); + return match.DurableExecutionArn; + } + } + } + catch (Exception ex) + { + _output.WriteLine($"[FindArn] attempt {attempt} error (will retry): {ex.Message}"); + } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + _output.WriteLine($"[FindArn] gave up after {attempt} attempts ({timeout.TotalSeconds}s)"); + return null; + } + + public async Task PollForCompletionAsync(string durableExecutionArn, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + try + { + var resp = await _lambdaClient.GetDurableExecutionAsync( + new GetDurableExecutionRequest { DurableExecutionArn = durableExecutionArn }); + + var status = resp.Status?.ToString(); + if (status == "SUCCEEDED" || status == "FAILED" || + status == "TIMED_OUT" || status == "STOPPED") + { + return status; + } + } + catch (Exception ex) + { + _output.WriteLine($"Poll error (will retry): {ex.Message}"); + } + + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + return "TIMEOUT"; + } + + public async Task GetExecutionAsync(string durableExecutionArn) + => await _lambdaClient.GetDurableExecutionAsync( + new GetDurableExecutionRequest { DurableExecutionArn = durableExecutionArn }); + + public async Task GetHistoryAsync(string durableExecutionArn, bool includeExecutionData = true) + => await _lambdaClient.GetDurableExecutionHistoryAsync( + new GetDurableExecutionHistoryRequest + { + DurableExecutionArn = durableExecutionArn, + IncludeExecutionData = includeExecutionData + }); + + /// + /// Repeatedly fetches history until is satisfied or the + /// timeout elapses. Needed because the history endpoint is eventually consistent — + /// the execution status can flip to SUCCEEDED before all events are indexed. + /// + public async Task WaitForHistoryAsync( + string durableExecutionArn, + Func predicate, + TimeSpan timeout, + bool includeExecutionData = true) + { + var deadline = DateTime.UtcNow + timeout; + GetDurableExecutionHistoryResponse? last = null; + var attempt = 0; + + while (DateTime.UtcNow < deadline) + { + attempt++; + try + { + last = await GetHistoryAsync(durableExecutionArn, includeExecutionData); + var eventCount = last.Events?.Count ?? 0; + _output.WriteLine($"[WaitForHistory] attempt {attempt}: {eventCount} events"); + if (predicate(last)) return last; + } + catch (Exception ex) + { + _output.WriteLine($"[WaitForHistory] attempt {attempt} error (will retry): {ex.Message}"); + } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + _output.WriteLine($"[WaitForHistory] gave up after {attempt} attempts; returning last response with {last?.Events?.Count ?? 0} events"); + return last ?? throw new TimeoutException($"GetDurableExecutionHistory never succeeded within {timeout.TotalSeconds}s"); + } + + public string? ExtractDurableExecutionArn(string responsePayload) + { + try + { + var doc = JsonDocument.Parse(responsePayload); + if (doc.RootElement.TryGetProperty("durableExecutionArn", out var arnProp)) + return arnProp.GetString(); + } + catch { } + return null; + } + + private async Task WaitForFunctionActive() + { + for (int i = 0; i < 60; i++) + { + try + { + var config = await _lambdaClient.GetFunctionConfigurationAsync( + new GetFunctionConfigurationRequest { FunctionName = _functionName }); + if (config.State == State.Active) return; + if (config.State == State.Failed) + throw new Exception($"Function creation failed: {config.StateReasonCode} - {config.StateReason}"); + } + catch (ResourceNotFoundException) { } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + throw new TimeoutException("Function did not become Active within 120 seconds"); + } + + private async Task BuildAndPushImage(string testFunctionDir, string repositoryUri) + { + var publishDir = Path.Combine(testFunctionDir, "bin", "publish"); + if (Directory.Exists(publishDir)) Directory.Delete(publishDir, true); + + await RunProcess("dotnet", + $"publish -c Release -r linux-x64 --self-contained true -o \"{publishDir}\"", + testFunctionDir); + + var imageTag = $"{repositoryUri}:latest"; + await RunProcess("docker", + $"build --platform linux/amd64 --provenance=false -t {imageTag} .", + testFunctionDir); + + var authResponse = await _ecrClient.GetAuthorizationTokenAsync(new GetAuthorizationTokenRequest()); + var authData = authResponse.AuthorizationData[0]; + var token = Encoding.UTF8.GetString(Convert.FromBase64String(authData.AuthorizationToken)); + var parts = token.Split(':'); + var registryUrl = authData.ProxyEndpoint; + + await RunProcess("docker", + $"login --username {parts[0]} --password-stdin {registryUrl}", + testFunctionDir, + stdin: parts[1]); + + await RunProcess("docker", $"push {imageTag}", testFunctionDir); + + return imageTag; + } + + private async Task RunProcess(string fileName, string arguments, string workingDir, string? stdin = null) + { + _output.WriteLine($"Running: {fileName} {arguments}"); + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdin != null, + UseShellExecute = false + }; + + var process = System.Diagnostics.Process.Start(psi)!; + + if (stdin != null) + { + await process.StandardInput.WriteAsync(stdin); + process.StandardInput.Close(); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await Task.WhenAny( + process.WaitForExitAsync(), + Task.Delay(TimeSpan.FromMinutes(5))); + + if (!process.HasExited) + { + process.Kill(); + throw new TimeoutException($"{fileName} timed out after 5 minutes"); + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + if (!string.IsNullOrWhiteSpace(stdout)) + _output.WriteLine($"stdout: {stdout[..Math.Min(stdout.Length, 1000)]}"); + + if (process.ExitCode != 0) + { + _output.WriteLine($"stderr: {stderr}"); + throw new Exception($"{fileName} failed (exit {process.ExitCode}): {stderr}"); + } + } + + public async ValueTask DisposeAsync() + { + if (_functionCreated) + { + try + { + _output.WriteLine($"Deleting function: {_functionName}"); + await _lambdaClient.DeleteFunctionAsync(new DeleteFunctionRequest { FunctionName = _functionName }); + } + catch (Exception ex) { _output.WriteLine($"Cleanup error (function): {ex.Message}"); } + } + + if (_ecrRepoCreated) + { + try + { + _output.WriteLine($"Deleting ECR repository: {_repoName}"); + await _ecrClient.DeleteRepositoryAsync(new DeleteRepositoryRequest + { + RepositoryName = _repoName, + Force = true + }); + } + catch (Exception ex) { _output.WriteLine($"Cleanup error (ECR): {ex.Message}"); } + } + + if (_roleArn != null) + { + // Detach each policy independently — if one detach fails (e.g., the + // policy was never attached because init bailed out early) we still + // want to attempt the others and the final DeleteRole. + await TryDetachPolicy("arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"); + await TryDetachPolicy("arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy"); + try + { + await _iamClient.DeleteRoleAsync(new DeleteRoleRequest { RoleName = _roleName }); + } + catch (Exception ex) { _output.WriteLine($"Cleanup error (IAM DeleteRole): {ex.Message}"); } + } + + async Task TryDetachPolicy(string policyArn) + { + try + { + await _iamClient.DetachRolePolicyAsync(new DetachRolePolicyRequest + { + RoleName = _roleName, + PolicyArn = policyArn + }); + } + catch (Exception ex) { _output.WriteLine($"Cleanup error (IAM Detach {policyArn}): {ex.Message}"); } + } + } + + public static string FindTestFunctionDir(string functionDirName) + { + var dir = AppContext.BaseDirectory; + while (dir != null) + { + var candidate = Path.Combine(dir, "TestFunctions", functionDirName); + if (Directory.Exists(candidate)) + return candidate; + + // Also check legacy "TestFunction" location for backwards compat + var legacy = Path.Combine(dir, functionDirName); + if (Directory.Exists(legacy) && File.Exists(Path.Combine(legacy, $"{functionDirName}.csproj"))) + return legacy; + + dir = Path.GetDirectoryName(dir); + } + + // Fallback: relative from test source directory + var fallback = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "TestFunctions", functionDirName)); + if (Directory.Exists(fallback)) + return fallback; + + throw new DirectoryNotFoundException( + $"Could not find TestFunctions/{functionDirName}/ directory. Looked up from: {AppContext.BaseDirectory}"); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/LongerWaitTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/LongerWaitTest.cs new file mode 100644 index 000000000..0592d0d44 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/LongerWaitTest.cs @@ -0,0 +1,62 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class LongerWaitTest +{ + private readonly ITestOutputHelper _output; + public LongerWaitTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task LongerWait_ExpiresAndCompletes() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("LongerWaitFunction"), + "longwait", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "long-wait-test"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(90)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Count(e => e.StepSucceededDetails != null) ?? 0) >= 2 + && (h.Events?.Any(e => e.WaitSucceededDetails != null) ?? false), + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // Steps before and after the wait both ran, with the post-wait step seeing + // the pre-wait step's value via replay. + var stepResults = events + .Where(e => e.StepSucceededDetails != null) + .Select(e => (Name: e.Name, Payload: e.StepSucceededDetails.Result?.Payload?.Trim('"'))) + .ToList(); + Assert.Equal(2, stepResults.Count); + Assert.Equal("before_wait", stepResults[0].Name); + Assert.Equal("started-long-wait-test", stepResults[0].Payload); + Assert.Equal("after_wait", stepResults[1].Name); + Assert.Equal("after_wait-started-long-wait-test", stepResults[1].Payload); + + // The wait was checkpointed for the configured 15-second duration. + var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "long_wait"); + Assert.NotNull(waitStarted); + Assert.Equal(15, waitStarted!.WaitStartedDetails.Duration); + + // The wait spanned at least two invocations: one to schedule it and at + // least one to resume after the timer fires. + var invocations = events.Where(e => e.InvocationCompletedDetails != null).ToList(); + Assert.True( + invocations.Count >= 2, + $"Expected at least 2 InvocationCompleted events (suspend + resume), got {invocations.Count}"); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MultipleStepsTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MultipleStepsTest.cs new file mode 100644 index 000000000..573ecc082 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MultipleStepsTest.cs @@ -0,0 +1,56 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class MultipleStepsTest +{ + private readonly ITestOutputHelper _output; + public MultipleStepsTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task MultipleSteps_AllCheckpointed() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("MultipleStepsFunction"), + "multi", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "chain"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + // History is eventually consistent — the execution can be SUCCEEDED before + // all events are indexed. Wait until we see all 5 step-succeeded events. + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Count(e => e.StepSucceededDetails != null) ?? 0) >= 5, + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // Each step ran exactly once (no replay-induced duplicates) in declaration order, + // and each step's output chained from the previous one. + var stepResults = events + .Where(e => e.StepSucceededDetails != null) + .Select(e => $"{e.Name}={e.StepSucceededDetails.Result?.Payload?.Trim('"')}") + .ToList(); + Assert.Equal( + new[] + { + "step_1=a-chain", + "step_2=a-chain-b", + "step_3=a-chain-b-c", + "step_4=a-chain-b-c-d", + "step_5=a-chain-b-c-d-e", + }, + stepResults); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ReplayDeterminismTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ReplayDeterminismTest.cs new file mode 100644 index 000000000..0fd7aa569 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ReplayDeterminismTest.cs @@ -0,0 +1,67 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class ReplayDeterminismTest +{ + private readonly ITestOutputHelper _output; + public ReplayDeterminismTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task ReplayDeterminism_SameGuidAcrossInvocations() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("ReplayDeterminismFunction"), + "replay", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "replay-test"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + // History is eventually consistent — wait until both step-succeeded events are visible. + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Count(e => e.StepSucceededDetails != null) ?? 0) >= 2, + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // Each step succeeded exactly once — generate_id was NOT re-executed on replay + // (a duplicate would show up as two succeeded events for the same name). + var stepSucceededEvents = events.Where(e => e.StepSucceededDetails != null).ToList(); + Assert.Equal(2, stepSucceededEvents.Count); + Assert.Single(stepSucceededEvents.Where(e => e.Name == "generate_id")); + Assert.Single(stepSucceededEvents.Where(e => e.Name == "echo_id")); + + var generateEvent = stepSucceededEvents.First(e => e.Name == "generate_id"); + var echoEvent = stepSucceededEvents.First(e => e.Name == "echo_id"); + + var generatedGuid = generateEvent.StepSucceededDetails.Result?.Payload?.Trim('"'); + var echoedResult = echoEvent.StepSucceededDetails.Result?.Payload?.Trim('"'); + Assert.NotNull(generatedGuid); + Assert.NotNull(echoedResult); + Assert.True(Guid.TryParse(generatedGuid, out _), + $"generate_id should produce a valid GUID, got: {generatedGuid}"); + + // The echoed value matches the cached GUID — proves replay returned the + // checkpointed value rather than running generate_id again. + Assert.Equal($"echo:{generatedGuid}", echoedResult); + + // The boundary wait actually caused a suspend/resume cycle. + var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "boundary_wait"); + Assert.NotNull(waitStarted); + var invocations = events.Where(e => e.InvocationCompletedDetails != null).ToList(); + Assert.True( + invocations.Count >= 2, + $"Expected at least 2 InvocationCompleted events (proves replay actually happened), got {invocations.Count}"); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepFailsTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepFailsTest.cs new file mode 100644 index 000000000..7b2afd427 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepFailsTest.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class StepFailsTest +{ + private readonly ITestOutputHelper _output; + public StepFailsTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task StepFails_PropagatesAsFailedStatus() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("StepFailsFunction"), + "stepfail", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "x"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + // Failed workflows return null payload to the Invoke caller. Locate the execution + // by name and verify the service marked it FAILED. + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("FAILED", status, ignoreCase: true); + + var execution = await deployment.GetExecutionAsync(arn!); + Assert.NotNull(execution.Error); + Assert.Contains("intentional failure", execution.Error.ErrorMessage); + + var history = await deployment.WaitForHistoryAsync( + arn!, + h => h.Events?.Any(e => e.StepFailedDetails != null) ?? false, + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // The failing step recorded a StepFailed event with the exception message. + var stepFailed = events.FirstOrDefault(e => e.StepFailedDetails != null && e.Name == "fail_step"); + Assert.NotNull(stepFailed); + Assert.Contains("intentional failure", stepFailed!.StepFailedDetails.Error?.Payload?.ErrorMessage ?? string.Empty); + + // No step ever succeeded — the workflow body was unreachable past the throw. + Assert.Empty(events.Where(e => e.StepSucceededDetails != null)); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepWaitStepTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepWaitStepTest.cs new file mode 100644 index 000000000..684486dd9 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/StepWaitStepTest.cs @@ -0,0 +1,58 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class StepWaitStepTest +{ + private readonly ITestOutputHelper _output; + public StepWaitStepTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task StepWaitStep_CompletesViaService() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("StepWaitStepFunction"), + "stepwait", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "integ-test-123"}"""); + Assert.Equal(200, invokeResponse.StatusCode); + + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Count(e => e.StepSucceededDetails != null) ?? 0) >= 2 + && (h.Events?.Any(e => e.WaitSucceededDetails != null) ?? false), + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // Both steps ran in order and produced the expected chained outputs. + var stepResults = events + .Where(e => e.StepSucceededDetails != null) + .Select(e => (Name: e.Name, Payload: e.StepSucceededDetails.Result?.Payload?.Trim('"'))) + .ToList(); + Assert.Equal(2, stepResults.Count); + Assert.Equal("validate", stepResults[0].Name); + Assert.Equal("validated-integ-test-123", stepResults[0].Payload); + Assert.Equal("process", stepResults[1].Name); + Assert.Equal("processed-validated-integ-test-123", stepResults[1].Payload); + + // The wait was actually scheduled with the expected duration. + var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "short_wait"); + Assert.NotNull(waitStarted); + Assert.Equal(3, waitStarted!.WaitStartedDetails.Duration); + var waitSucceeded = events.FirstOrDefault(e => e.WaitSucceededDetails != null && e.Name == "short_wait"); + Assert.NotNull(waitSucceeded); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs new file mode 100644 index 000000000..e73a6da7e --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs @@ -0,0 +1,40 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + var step1 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"started-{input.OrderId}"; }, + name: "before_wait"); + + await context.WaitAsync(TimeSpan.FromSeconds(15), name: "long_wait"); + + var step2 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"after_wait-{step1}"; }, + name: "after_wait"); + + return new TestResult { Status = "completed", Data = step2 }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/LongerWaitFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/LongerWaitFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/LongerWaitFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs new file mode 100644 index 000000000..cc80e6afa --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs @@ -0,0 +1,50 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + var step1 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"a-{input.OrderId}"; }, + name: "step_1"); + + var step2 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"{step1}-b"; }, + name: "step_2"); + + var step3 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"{step2}-c"; }, + name: "step_3"); + + var step4 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"{step3}-d"; }, + name: "step_4"); + + var step5 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"{step4}-e"; }, + name: "step_5"); + + return new TestResult { Status = "completed", Data = step5 }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/MultipleStepsFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/MultipleStepsFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/MultipleStepsFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs new file mode 100644 index 000000000..ce2a333b1 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs @@ -0,0 +1,43 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + // Step 1 generates a fresh GUID. On replay, this MUST return the cached value. + var generatedId = await context.StepAsync( + async (_) => { await Task.CompletedTask; return Guid.NewGuid().ToString(); }, + name: "generate_id"); + + // Force a suspend/resume cycle to trigger replay + await context.WaitAsync(TimeSpan.FromSeconds(3), name: "boundary_wait"); + + // Step 2 echoes the GUID. After replay, it should see the SAME GUID from step 1. + var echoed = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"echo:{generatedId}"; }, + name: "echo_id"); + + return new TestResult { Status = "completed", Data = echoed }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/ReplayDeterminismFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/ReplayDeterminismFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/ReplayDeterminismFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs new file mode 100644 index 000000000..9aeeed2a2 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs @@ -0,0 +1,38 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + await context.StepAsync( + async (_) => + { + await Task.CompletedTask; + throw new InvalidOperationException("intentional failure for integration test"); + }, + name: "fail_step"); + + return new TestResult { Status = "should_not_reach" }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/StepFailsFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/StepFailsFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/StepFailsFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs new file mode 100644 index 000000000..5b6c291df --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs @@ -0,0 +1,40 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + var step1 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"validated-{input.OrderId}"; }, + name: "validate"); + + await context.WaitAsync(TimeSpan.FromSeconds(3), name: "short_wait"); + + var step2 = await context.StepAsync( + async (_) => { await Task.CompletedTask; return $"processed-{step1}"; }, + name: "process"); + + return new TestResult { Status = "completed", Data = step2 }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/StepWaitStepFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/StepWaitStepFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/StepWaitStepFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Dockerfile new file mode 100644 index 000000000..c1913d56a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Dockerfile @@ -0,0 +1,7 @@ +FROM public.ecr.aws/lambda/provided:al2023 + +RUN dnf install -y libicu + +COPY bin/publish/ ${LAMBDA_TASK_ROOT} + +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs new file mode 100644 index 000000000..54e4ab737 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs @@ -0,0 +1,31 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionTestFunction; + +public class Function +{ + public static async Task Main(string[] args) + { + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) + { + await context.WaitAsync(TimeSpan.FromSeconds(5), name: "only_wait"); + return new TestResult { Status = "completed", Data = "wait_only" }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/WaitOnlyFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/WaitOnlyFunction.csproj new file mode 100644 index 000000000..6f5f657e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/WaitOnlyFunction.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + Exe + true + bootstrap + enable + enable + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitOnlyTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitOnlyTest.cs new file mode 100644 index 000000000..213ce0186 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitOnlyTest.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +public class WaitOnlyTest +{ + private readonly ITestOutputHelper _output; + public WaitOnlyTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task WaitOnly_NoSteps() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("WaitOnlyFunction"), + "waitonly", _output); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "wait-only"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Any(e => e.WaitSucceededDetails != null) ?? false), + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + // The wait was checkpointed and ran for the configured duration. + var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "only_wait"); + Assert.NotNull(waitStarted); + Assert.Equal(5, waitStarted!.WaitStartedDetails.Duration); + + var waitSucceeded = events.FirstOrDefault(e => e.WaitSucceededDetails != null && e.Name == "only_wait"); + Assert.NotNull(waitSucceeded); + + // No step events: this workflow body contains only a wait. + Assert.Empty(events.Where(e => e.StepStartedDetails != null)); + + // The wait genuinely caused a suspend/resume, not an in-process delay: + // expect at least 2 invocations recorded (initial + resume after timer fires). + var invocations = events.Where(e => e.InvocationCompletedDetails != null).ToList(); + Assert.True( + invocations.Count >= 2, + $"Expected at least 2 InvocationCompleted events (initial + post-wait resume), got {invocations.Count}"); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/xunit.runner.json b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/xunit.runner.json new file mode 100644 index 000000000..b6de9b357 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false, + "parallelizeAssembly": false, + "maxParallelThreads": 1 +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj index d8d1615c9..6fa422e0a 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj @@ -11,17 +11,20 @@ true enable enable - $(NoWarn);CS1591 + $(NoWarn);CS1591 + true + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/AssemblyLoadTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/AssemblyLoadTests.cs deleted file mode 100644 index 84295a2e1..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/AssemblyLoadTests.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Xunit; - -namespace Amazon.Lambda.DurableExecution.Tests; - -public class AssemblyLoadTests -{ - [Fact] - public void DurableExecutionAssembly_Loads() - { - var assembly = typeof(AssemblyMarker).Assembly; - Assert.Equal("Amazon.Lambda.DurableExecution", assembly.GetName().Name); - } -} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/CheckpointBatcherTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/CheckpointBatcherTests.cs new file mode 100644 index 000000000..c81998eaa --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/CheckpointBatcherTests.cs @@ -0,0 +1,213 @@ +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class CheckpointBatcherTests +{ + private static SdkOperationUpdate Update(string id) => new() + { + Id = id, + Type = "STEP", + Action = "SUCCEED" + }; + + [Fact] + public async Task EnqueueAsync_AwaitsUntilBatchFlushes() + { + var flushedTokens = new List(); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + flushedTokens.Add(token); + return Task.FromResult("token-1"); + }); + + await batcher.EnqueueAsync(Update("0-step")); + + Assert.Equal(new string?[] { "token-0" }, flushedTokens); + Assert.Equal("token-1", batcher.CheckpointToken); + + await batcher.DrainAsync(); + } + + [Fact] + public async Task MultipleEnqueueAsync_BatchedWithinWindow() + { + var batches = new List(); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + batches.Add(ops.Count); + return Task.FromResult(token); + }, + new CheckpointBatcherConfig { FlushInterval = TimeSpan.FromMilliseconds(50) }); + + // Fire several enqueues concurrently and await all — they should + // coalesce into a single batch since FlushInterval > 0. + var tasks = Enumerable.Range(0, 5) + .Select(i => batcher.EnqueueAsync(Update($"{i}-step"))) + .ToArray(); + + await Task.WhenAll(tasks); + await batcher.DrainAsync(); + + Assert.Single(batches); + Assert.Equal(5, batches[0]); + } + + [Fact] + public async Task EnqueueAsync_OverflowOps_SplitsBatches() + { + var batches = new List(); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + batches.Add(ops.Count); + return Task.FromResult(token); + }, + new CheckpointBatcherConfig + { + MaxBatchOperations = 3, + FlushInterval = TimeSpan.FromMilliseconds(100) + }); + + var tasks = Enumerable.Range(0, 7) + .Select(i => batcher.EnqueueAsync(Update($"{i}-step"))) + .ToArray(); + + await Task.WhenAll(tasks); + await batcher.DrainAsync(); + + // 7 items, max 3 per batch → 3, 3, 1 (or some permutation summing to 7 + // with no batch over 3). + Assert.Equal(7, batches.Sum()); + Assert.All(batches, count => Assert.True(count <= 3)); + Assert.True(batches.Count >= 3); + } + + [Fact] + public async Task FlushAsync_Throws_PropagatesToAllAwaiters() + { + var failure = new InvalidOperationException("service unavailable"); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => Task.FromException(failure), + new CheckpointBatcherConfig { FlushInterval = TimeSpan.FromMilliseconds(50) }); + + var tasks = Enumerable.Range(0, 3) + .Select(i => batcher.EnqueueAsync(Update($"{i}-step"))) + .ToArray(); + + // Each awaiter should see the same exception. + foreach (var t in tasks) + { + var ex = await Assert.ThrowsAsync(() => t); + Assert.Equal("service unavailable", ex.Message); + } + } + + [Fact] + public async Task EnqueueAsync_AfterTerminalError_FailsFast() + { + var failure = new InvalidOperationException("kaboom"); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => Task.FromException(failure)); + + // First enqueue trips the terminal error. + await Assert.ThrowsAsync(() => batcher.EnqueueAsync(Update("0-step"))); + + // Subsequent enqueue should fail fast with the same exception. + var second = await Assert.ThrowsAsync(() => batcher.EnqueueAsync(Update("1-step"))); + Assert.Equal("kaboom", second.Message); + } + + [Fact] + public async Task DrainAsync_FlushesRemainingItems() + { + var totalFlushed = 0; + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + Interlocked.Add(ref totalFlushed, ops.Count); + return Task.FromResult(token); + }); + + // Fire enqueues without awaiting them individually. + var tasks = Enumerable.Range(0, 4) + .Select(i => batcher.EnqueueAsync(Update($"{i}-step"))) + .ToArray(); + + await batcher.DrainAsync(); + await Task.WhenAll(tasks); + + Assert.Equal(4, totalFlushed); + } + + [Fact] + public async Task DrainAsync_AfterTerminalError_Throws() + { + var failure = new InvalidOperationException("nope"); + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => Task.FromException(failure)); + + // Trip the terminal error. + await Assert.ThrowsAsync(() => batcher.EnqueueAsync(Update("0-step"))); + + // Drain should rethrow. + await Assert.ThrowsAsync(() => batcher.DrainAsync()); + } + + [Fact] + public async Task EnqueueAsync_AfterDispose_Throws() + { + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => Task.FromResult(token)); + + await batcher.DisposeAsync(); + + await Assert.ThrowsAnyAsync(() => batcher.EnqueueAsync(Update("0-step"))); + } + + [Fact] + public async Task CheckpointToken_UpdatesAfterEachFlush() + { + var counter = 0; + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + var next = $"token-{Interlocked.Increment(ref counter)}"; + return Task.FromResult(next); + }); + + await batcher.EnqueueAsync(Update("0-step")); + Assert.Equal("token-1", batcher.CheckpointToken); + + await batcher.EnqueueAsync(Update("1-step")); + Assert.Equal("token-2", batcher.CheckpointToken); + + await batcher.DrainAsync(); + } + + [Fact] + public async Task ConcurrentEnqueueAsync_AllComplete() + { + var totalFlushed = 0; + var batcher = new CheckpointBatcher("token-0", + (token, ops, ct) => + { + Interlocked.Add(ref totalFlushed, ops.Count); + return Task.FromResult(token); + }, + new CheckpointBatcherConfig { FlushInterval = TimeSpan.FromMilliseconds(20) }); + + var tasks = Enumerable.Range(0, 100) + .Select(i => Task.Run(() => batcher.EnqueueAsync(Update($"{i}-step")))) + .ToArray(); + + await Task.WhenAll(tasks); + await batcher.DrainAsync(); + + Assert.Equal(100, totalFlushed); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs new file mode 100644 index 000000000..f31586ea0 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs @@ -0,0 +1,15 @@ +using Amazon.Lambda.DurableExecution; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class ConfigTests +{ + [Fact] + public void SerializationContext_RecordEquality() + { + var ctx1 = new SerializationContext("op-1", "arn:aws:lambda:us-east-1:123:function:my-func"); + var ctx2 = new SerializationContext("op-1", "arn:aws:lambda:us-east-1:123:function:my-func"); + Assert.Equal(ctx1, ctx2); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs new file mode 100644 index 000000000..806ebd844 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs @@ -0,0 +1,669 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.TestUtilities; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class DurableContextTests +{ + /// Reproduces the Id that emits for the n-th root-level operation. + private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); + + private static DurableContext CreateContext( + InitialExecutionState? initialState = null, + TerminationManager? terminationManager = null) + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(initialState); + var tm = terminationManager ?? new TerminationManager(); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + + return new DurableContext(state, tm, idGen, "arn:aws:lambda:us-east-1:123:durable-execution:test", lambdaContext); + } + + #region StepAsync Tests + + [Fact] + public async Task StepAsync_NewExecution_RunsFunction() + { + var context = CreateContext(); + var executed = false; + + var result = await context.StepAsync(async (_) => + { + executed = true; + await Task.CompletedTask; + return 42; + }, name: "my_step"); + + Assert.True(executed); + Assert.Equal(42, result); + } + + [Fact] + public async Task StepAsync_Replay_ReturnsCachedResult() + { + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "\"cached_value\"" } + } + } + }); + + var executed = false; + var result = await context.StepAsync(async (_) => + { + executed = true; + await Task.CompletedTask; + return "fresh_value"; + }, name: "cached_step"); + + Assert.False(executed); + Assert.Equal("cached_value", result); + } + + [Fact] + public async Task StepAsync_ReplayFailed_ThrowsStepException() + { + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Failed, + StepDetails = new StepDetails + { + Error = new ErrorObject + { + ErrorType = "System.TimeoutException", + ErrorMessage = "timed out" + } + } + } + } + }); + + var ex = await Assert.ThrowsAsync(() => + context.StepAsync(async (_) => { await Task.CompletedTask; return "x"; }, name: "bad_step")); + + Assert.Equal("System.TimeoutException", ex.ErrorType); + Assert.Equal("timed out", ex.Message); + } + + [Fact] + public async Task StepAsync_Throws_FailsWithStepException() + { + var context = CreateContext(); + var attempts = 0; + + await Assert.ThrowsAsync(() => + context.StepAsync(async (_) => + { + attempts++; + await Task.CompletedTask; + throw new InvalidOperationException("boom"); + }, name: "fail_step")); + + // No retry support yet — the step runs once. + Assert.Equal(1, attempts); + } + + [Fact] + public async Task StepAsync_WithStepContext_ReceivesMetadata() + { + var context = CreateContext(); + string? receivedOpId = null; + int receivedAttempt = 0; + Microsoft.Extensions.Logging.ILogger? receivedLogger = null; + + await context.StepAsync(async (step) => + { + receivedOpId = step.OperationId; + receivedAttempt = step.AttemptNumber; + receivedLogger = step.Logger; + await Task.CompletedTask; + return "done"; + }, name: "meta_step"); + + Assert.Equal(IdAt(1), receivedOpId); + Assert.Equal(1, receivedAttempt); + Assert.NotNull(receivedLogger); + } + + [Fact] + public async Task StepAsync_VoidOverload_Works() + { + var context = CreateContext(); + var executed = false; + + await context.StepAsync(async (_) => + { + executed = true; + await Task.CompletedTask; + }, name: "void_step"); + + Assert.True(executed); + } + + [Fact] + public async Task StepAsync_MultipleSteps_DeterministicIds() + { + var context = CreateContext(); + + var r1 = await context.StepAsync(async (_) => { await Task.CompletedTask; return "a"; }, name: "first"); + var r2 = await context.StepAsync(async (_) => { await Task.CompletedTask; return "b"; }, name: "second"); + var r3 = await context.StepAsync(async (_) => { await Task.CompletedTask; return "c"; }); + + Assert.Equal("a", r1); + Assert.Equal("b", r2); + Assert.Equal("c", r3); + } + + [Fact] + public async Task StepAsync_ComplexType_SerializesCorrectly() + { + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "{\"Name\":\"Alice\",\"Age\":30}" } + } + } + }); + + var result = await context.StepAsync( + async (_) => { await Task.CompletedTask; return new TestPerson { Name = "Bob", Age = 25 }; }, + name: "fetch"); + + Assert.Equal("Alice", result.Name); + Assert.Equal(30, result.Age); + } + + [Fact] + public async Task StepAsync_CustomSerializer_UsedForSerialization() + { + var serializer = new RecordingSerializer(); + var context = CreateContext(); + + var result = await context.StepAsync( + async (_) => { await Task.CompletedTask; return new TestPerson { Name = "Charlie", Age = 40 }; }, + serializer, + name: "with_custom"); + + Assert.Equal("Charlie", result.Name); + Assert.True(serializer.SerializeCalled); + Assert.False(serializer.DeserializeCalled); + } + + [Fact] + public void Logger_Defaults_ToNullLogger() + { + var context = CreateContext(); + Assert.NotNull(context.Logger); + } + + [Fact] + public void ExecutionContext_ExposesArn() + { + var context = CreateContext(); + Assert.Equal("arn:aws:lambda:us-east-1:123:durable-execution:test", context.ExecutionContext.DurableExecutionArn); + } + + [Fact] + public void LambdaContext_IsExposed() + { + var context = CreateContext(); + Assert.NotNull(context.LambdaContext); + } + + [Fact] + public async Task StepAsync_Replay_NullResult_ReturnsDefault() + { + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = null } + } + } + }); + + var result = await context.StepAsync( + async (_) => { await Task.CompletedTask; return "fresh"; }, + name: "no_result"); + + Assert.Null(result); + } + + [Fact] + public async Task StepAsync_CancelledToken_ThrowsOperationCanceled() + { + var context = CreateContext(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + context.StepAsync( + async (_) => + { + cts.Token.ThrowIfCancellationRequested(); + await Task.CompletedTask; + return "unreachable"; + }, + name: "cancelled_step", + cancellationToken: cts.Token)); + } + + [Fact] + public async Task StepAsync_CustomSerializer_UsedForReplayDeserialization() + { + var serializer = new RecordingSerializer(); + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "Dana,55" } + } + } + }); + + var result = await context.StepAsync( + async (_) => { await Task.CompletedTask; return new TestPerson { Name = "ignored", Age = 0 }; }, + serializer, + name: "replay_step"); + + Assert.True(serializer.DeserializeCalled); + Assert.Equal("Dana", result.Name); + Assert.Equal(55, result.Age); + } + + #endregion + + #region WaitAsync Tests + + [Fact] + public async Task WaitAsync_SubSecond_ThrowsArgumentOutOfRange() + { + var context = CreateContext(); + + await Assert.ThrowsAsync(() => + context.WaitAsync(TimeSpan.FromMilliseconds(500))); + } + + [Fact] + public async Task WaitAsync_AboveOneYear_ThrowsArgumentOutOfRange() + { + var context = CreateContext(); + + await Assert.ThrowsAsync(() => + context.WaitAsync(TimeSpan.FromSeconds(31_622_401))); + } + + [Fact] + public async Task WaitAsync_NewExecution_SignalsTermination() + { + var tm = new TerminationManager(); + var context = CreateContext(terminationManager: tm); + + // WaitAsync should signal termination and return a never-completing task + var waitTask = context.WaitAsync(TimeSpan.FromSeconds(30), name: "my_wait"); + + // Give it a moment to execute + await Task.Delay(10); + + Assert.True(tm.IsTerminated); + Assert.False(waitTask.IsCompleted); + } + + [Fact] + public async Task WaitAsync_Elapsed_ContinuesImmediately() + { + var pastExpirationMs = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeMilliseconds(); + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Wait, + Status = OperationStatuses.Pending, + WaitDetails = new WaitDetails { ScheduledEndTimestamp = pastExpirationMs } + } + } + }); + + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "cooldown"); + // If we got here, the wait was correctly skipped + } + + [Fact] + public async Task WaitAsync_StartedButNotExpired_ResuspendsWithoutNewCheckpoint() + { + var futureExpirationMs = DateTimeOffset.UtcNow.AddSeconds(300).ToUnixTimeMilliseconds(); + var tm = new TerminationManager(); + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Wait, + Status = OperationStatuses.Pending, + WaitDetails = new WaitDetails { ScheduledEndTimestamp = futureExpirationMs } + } + } + }); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var recorder = new RecordingBatcher(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext, recorder.Batcher); + + var waitTask = context.WaitAsync(TimeSpan.FromSeconds(30), name: "pending_wait"); + + await Task.Delay(10); + + Assert.True(tm.IsTerminated); + Assert.False(waitTask.IsCompleted); + Assert.Empty(recorder.Flushed); + } + + [Fact] + public async Task WaitAsync_AlreadySucceeded_ContinuesImmediately() + { + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Wait, + Status = OperationStatuses.Succeeded + } + } + }); + + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "done_wait"); + // Completed without blocking + } + + [Fact] + public async Task WaitAsync_UnknownStatus_ThrowsNonDeterministicException() + { + // Unrecognized status on a replayed wait checkpoint must surface as + // NonDeterministicExecutionException — silently re-emitting WAIT START + // would either fail at the service or duplicate work. + var context = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Wait, + Status = "TOTALLY_BOGUS_STATUS" + } + } + }); + + await Assert.ThrowsAsync(() => + context.WaitAsync(TimeSpan.FromSeconds(30), name: "mystery_wait")); + } + + #endregion + + #region End-to-end: Step + Wait + Step + + [Fact] + public async Task EndToEnd_StepWaitStep_FirstInvocation_SuspendsOnWait() + { + var tm = new TerminationManager(); + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); + + var result = await DurableExecutionHandler.RunAsync( + state, tm, + async () => + { + await context.StepAsync(async (_) => { await Task.CompletedTask; return "fetched"; }, name: "fetch"); + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "delay"); + var final = await context.StepAsync(async (_) => { await Task.CompletedTask; return "processed"; }, name: "process"); + return final; + }); + + Assert.Equal(InvocationStatus.Pending, result.Status); + } + + [Fact] + public async Task EndToEnd_StepWaitStep_SecondInvocation_Completes() + { + var pastExpirationMs = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(); + var tm = new TerminationManager(); + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "\"fetched\"" } + }, + new() + { + Id = IdAt(2), + Type = OperationTypes.Wait, + Status = OperationStatuses.Pending, + WaitDetails = new WaitDetails { ScheduledEndTimestamp = pastExpirationMs } + } + } + }); + + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); + var processExecuted = false; + + var result = await DurableExecutionHandler.RunAsync( + state, tm, + async () => + { + var fetched = await context.StepAsync(async (_) => { await Task.CompletedTask; return "fresh_fetch"; }, name: "fetch"); + Assert.Equal("fetched", fetched); // cached from replay + + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "delay"); + // wait is elapsed, continues + + var final = await context.StepAsync(async (_) => + { + processExecuted = true; + await Task.CompletedTask; + return "processed"; + }, name: "process"); + return final; + }); + + Assert.Equal(InvocationStatus.Succeeded, result.Status); + Assert.Equal("processed", result.Result); + Assert.True(processExecuted); + } + + #endregion + + #region Non-Determinism Detection Tests + + [Fact] + public async Task StepAsync_ReplayTypeMismatch_ThrowsNonDeterministicException() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Wait, + Status = OperationStatuses.Succeeded + } + } + }); + var tm = new TerminationManager(); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); + + var ex = await Assert.ThrowsAsync(async () => + await context.StepAsync( + async (_) => { await Task.CompletedTask; return "should not run"; }, + name: "my_op")); + + Assert.Contains("expected type 'STEP'", ex.Message); + Assert.Contains("found 'WAIT'", ex.Message); + } + + [Fact] + public async Task WaitAsync_ReplayTypeMismatch_ThrowsNonDeterministicException() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "\"hello\"" } + } + } + }); + var tm = new TerminationManager(); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); + + var ex = await Assert.ThrowsAsync(async () => + await context.WaitAsync(TimeSpan.FromSeconds(10), name: "my_op")); + + Assert.Contains("expected type 'WAIT'", ex.Message); + Assert.Contains("found 'STEP'", ex.Message); + } + + [Fact] + public async Task StepAsync_ReplayNameMismatch_ThrowsNonDeterministicException() + { + // Simulate a scenario where the operation was stored with a different name + // than what the current code passes (e.g., service returned stale data). + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + Name = "old_name", + StepDetails = new StepDetails { Result = "\"old_result\"" } + } + } + }); + var tm = new TerminationManager(); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); + + var ex = await Assert.ThrowsAsync(async () => + await context.StepAsync( + async (_) => { await Task.CompletedTask; return "new"; }, + name: "my_step")); + + Assert.Contains("expected name 'my_step'", ex.Message); + Assert.Contains("found 'old_name'", ex.Message); + } + + [Fact] + public async Task StepAsync_NoReplay_SkipsValidation() + { + var context = CreateContext(); + + var result = await context.StepAsync( + async (_) => { await Task.CompletedTask; return "ok"; }, + name: "anything"); + + Assert.Equal("ok", result); + } + + #endregion + + private class TestPerson + { + public string? Name { get; set; } + public int Age { get; set; } + } + + /// + /// AOT-friendly test serializer using a trivial format. Demonstrates that + /// passing an to the AOT-safe + /// StepAsync overload fully replaces the reflection-based + /// System.Text.Json path. + /// + private class RecordingSerializer : ICheckpointSerializer + { + public bool SerializeCalled { get; private set; } + public bool DeserializeCalled { get; private set; } + + public string Serialize(TestPerson value, SerializationContext context) + { + SerializeCalled = true; + return $"{value.Name},{value.Age}"; + } + + public TestPerson Deserialize(string data, SerializationContext context) + { + DeserializeCalled = true; + var inner = data.Replace("", "").Replace("", ""); + var parts = inner.Split(','); + return new TestPerson { Name = parts[0], Age = int.Parse(parts[1]) }; + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableExecutionHandlerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableExecutionHandlerTests.cs new file mode 100644 index 000000000..b5abc5882 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableExecutionHandlerTests.cs @@ -0,0 +1,137 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class DurableExecutionHandlerTests +{ + [Fact] + public async Task RunAsync_UserCodeCompletes_ReturnsSucceeded() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + await Task.Delay(1); + return "hello"; + }); + + Assert.Equal(InvocationStatus.Succeeded, result.Status); + Assert.Equal("hello", result.Result); + Assert.Null(result.Exception); + } + + [Fact] + public async Task RunAsync_UserCodeThrows_ReturnsFailed() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + await Task.Delay(1); + throw new InvalidOperationException("something broke"); + }); + + Assert.Equal(InvocationStatus.Failed, result.Status); + Assert.Equal("something broke", result.Message); + Assert.IsType(result.Exception); + } + + [Fact] + public async Task RunAsync_TerminationWins_ReturnsPending() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + // Simulate: user code hits a wait, signals termination, then blocks forever + termination.Terminate(TerminationReason.WaitScheduled, "waiting 30s"); + await new TaskCompletionSource().Task; // blocks forever + return "unreachable"; + }); + + Assert.Equal(InvocationStatus.Pending, result.Status); + Assert.Equal("waiting 30s", result.Message); + Assert.Null(result.Exception); + } + + [Fact] + public async Task RunAsync_TerminationWithException_ReturnsFailed() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + termination.Terminate( + TerminationReason.CheckpointFailed, + "checkpoint error", + new InvalidOperationException("service unavailable")); + await new TaskCompletionSource().Task; + return "unreachable"; + }); + + Assert.Equal(InvocationStatus.Failed, result.Status); + Assert.IsType(result.Exception); + } + + [Fact] + public async Task RunAsync_FastUserCode_BeatsTermination() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + // User code completes before termination is called + return 42; + }); + + Assert.Equal(InvocationStatus.Succeeded, result.Status); + Assert.Equal(42, result.Result); + } + + [Fact] + public async Task RunAsync_IntResult_WorksWithValueTypes() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + var termination = new TerminationManager(); + + var result = await DurableExecutionHandler.RunAsync( + state, + termination, + async () => + { + await Task.CompletedTask; + return 100; + }); + + Assert.Equal(InvocationStatus.Succeeded, result.Status); + Assert.Equal(100, result.Result); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs new file mode 100644 index 000000000..032a25a66 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -0,0 +1,583 @@ +using System.Net; +using System.Text.Json; +using Amazon.Lambda; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.TestUtilities; +using Amazon.Runtime; +using Xunit; +using Operation = Amazon.Lambda.DurableExecution.Internal.Operation; +using StepDetails = Amazon.Lambda.DurableExecution.Internal.StepDetails; +using WaitDetails = Amazon.Lambda.DurableExecution.Internal.WaitDetails; +using ExecutionDetails = Amazon.Lambda.DurableExecution.Internal.ExecutionDetails; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class DurableFunctionTests +{ + /// Reproduces the Id that emits for the n-th root-level operation. + private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); + + private readonly IAmazonLambda _mockClient = new MockLambdaClient(); + + [Fact] + public async Task WrapAsync_FreshExecution_StepThenWait_ReturnsPending() + { + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:order-123", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-123\"}" } + } + } + } + }; + + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Pending, output.Status); + } + + [Fact] + public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() + { + var pastExpirationMs = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(); + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:order-123", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-123\"}" } + }, + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "{\"IsValid\":true}" } + }, + new() + { + Id = IdAt(2), + Type = OperationTypes.Wait, + Status = OperationStatuses.Pending, + WaitDetails = new WaitDetails { ScheduledEndTimestamp = pastExpirationMs } + } + } + } + }; + + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + Assert.NotNull(output.Result); + var result = JsonSerializer.Deserialize(output.Result!); + Assert.Equal("approved", result!.Status); + } + + [Fact] + public async Task WrapAsync_WorkflowThrows_ReturnsFailed() + { + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:fail-test", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"bad-order\"}" } + } + } + } + }; + + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => throw new InvalidOperationException("workflow error"), + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Failed, output.Status); + Assert.NotNull(output.Error); + Assert.Equal("workflow error", output.Error!.ErrorMessage); + Assert.Contains("InvalidOperationException", output.Error.ErrorType!); + } + + [Fact] + public async Task WrapAsync_VoidWorkflow_ReturnSucceeded() + { + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:void-test", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-1\"}" } + } + } + } + }; + + var executed = false; + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => + { + await ctx.StepAsync(async (_) => { await Task.CompletedTask; executed = true; }, name: "do_work"); + }, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + Assert.True(executed); + } + + [Fact] + public async Task WrapAsync_CheckpointsAreSentToService() + { + var mockClient = new MockLambdaClient(); + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:checkpoint-test", + CheckpointToken = "initial-token", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-1\"}" } + } + } + } + }; + + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + new TestLambdaContext(), + mockClient); + + Assert.Equal(InvocationStatus.Pending, output.Status); + Assert.Equal(2, mockClient.CheckpointCalls.Count); + + // First flush: step SUCCEED (the user awaits StepAsync, which awaits + // its SUCCEED enqueue, which blocks until the batcher flushes it). + var firstCall = mockClient.CheckpointCalls[0]; + Assert.Equal("arn:aws:lambda:us-east-1:123:durable-execution:checkpoint-test", firstCall.DurableExecutionArn); + Assert.Equal("initial-token", firstCall.CheckpointToken); + Assert.Single(firstCall.Updates); + var stepUpdate = firstCall.Updates[0]; + Assert.Equal("STEP", stepUpdate.Type); + Assert.Equal("SUCCEED", stepUpdate.Action); + Assert.Equal("validate", stepUpdate.Name); + Assert.NotNull(stepUpdate.Payload); + + // Second flush: wait START (blocks until the service has the timer + // recorded before WaitAsync suspends). + var secondCall = mockClient.CheckpointCalls[1]; + Assert.Single(secondCall.Updates); + var waitUpdate = secondCall.Updates[0]; + Assert.Equal("WAIT", waitUpdate.Type); + Assert.Equal("START", waitUpdate.Action); + Assert.Equal("delay", waitUpdate.Name); + Assert.NotNull(waitUpdate.WaitOptions); + Assert.Equal(30, waitUpdate.WaitOptions.WaitSeconds); + } + + [Fact] + public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() + { + // The wire payload uses camelCase ("orderId"), the user POCO uses PascalCase (OrderId). + // ExtractUserPayload must do case-insensitive binding so workflows can read input.OrderId. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:case-test", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"abc-123\"}" } + } + } + } + }; + + string? observedOrderId = null; + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => + { + observedOrderId = evt.OrderId; + await Task.CompletedTask; + return new OrderResult { Status = "ok", OrderId = evt.OrderId }; + }, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + Assert.Equal("abc-123", observedOrderId); + } + + [Fact] + public async Task WrapAsync_NoExecutionOp_ReceivesDefaultPayload() + { + // No EXECUTION operation in the envelope — ExtractUserPayload returns default(TInput). + // Exercises the "loop falls through without finding EXECUTION" branch in DurableFunction.ExtractUserPayload. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:no-exec", + InitialExecutionState = new InitialExecutionState + { + Operations = new List() + } + }; + + OrderEvent? observed = null; + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => + { + observed = evt; + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + Assert.Null(observed); // default(OrderEvent) for a reference type is null + } + + [Fact] + public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() + { + // The service can return execution state across multiple pages — the first + // page comes inline on the invocation envelope (InitialExecutionState) and + // subsequent pages must be fetched via GetDurableExecutionState. Verify the + // pagination loop in WrapAsyncCore (DurableFunction.cs:160-167) walks every + // page so the workflow sees the full operation history on replay. + var arn = "arn:aws:lambda:us-east-1:123:durable-execution:paginated"; + + // Page 0 (in InitialExecutionState): EXECUTION op + step1 SUCCEEDED. + // Page 1 (fetched with marker "marker-1"): step2 SUCCEEDED, points to marker-2. + // Page 2 (fetched with marker "marker-2"): step3 SUCCEEDED, no NextMarker — loop exits. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = arn, + CheckpointToken = "ckpt-0", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-1\"}" } + }, + new() + { + Id = IdAt(1), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "\"page-0-result\"" } + } + }, + NextMarker = "marker-1" + } + }; + + var mockClient = new MockLambdaClient + { + GetExecutionStateHandler = req => req.Marker switch + { + "marker-1" => new Amazon.Lambda.Model.GetDurableExecutionStateResponse + { + Operations = new List + { + new() + { + Id = IdAt(2), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new Amazon.Lambda.Model.StepDetails { Result = "\"page-1-result\"" } + } + }, + NextMarker = "marker-2" + }, + "marker-2" => new Amazon.Lambda.Model.GetDurableExecutionStateResponse + { + Operations = new List + { + new() + { + Id = IdAt(3), + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new Amazon.Lambda.Model.StepDetails { Result = "\"page-2-result\"" } + } + } + // NextMarker omitted -> loop terminates. + }, + _ => throw new InvalidOperationException($"Unexpected marker: {req.Marker}") + } + }; + + var observed = new List(); + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => + { + // All three steps must replay the cached results from the paginated state + // without re-executing — if the loop missed a page, the corresponding step + // would run fresh and append a different value to `observed`. + observed.Add(await ctx.StepAsync( + async (_) => { await Task.CompletedTask; return "fresh"; }, name: "step1")); + observed.Add(await ctx.StepAsync( + async (_) => { await Task.CompletedTask; return "fresh"; }, name: "step2")); + observed.Add(await ctx.StepAsync( + async (_) => { await Task.CompletedTask; return "fresh"; }, name: "step3")); + return new OrderResult { Status = "ok", OrderId = evt.OrderId }; + }, + input, + new TestLambdaContext(), + mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + + // Two GetDurableExecutionState calls — one per fetched page (page 0 was inline). + Assert.Equal(2, mockClient.GetExecutionStateCalls.Count); + Assert.Equal("marker-1", mockClient.GetExecutionStateCalls[0].Marker); + Assert.Equal(arn, mockClient.GetExecutionStateCalls[0].DurableExecutionArn); + Assert.Equal("ckpt-0", mockClient.GetExecutionStateCalls[0].CheckpointToken); + Assert.Equal("marker-2", mockClient.GetExecutionStateCalls[1].Marker); + + // The workflow saw replayed results from ALL three pages — none re-executed. + Assert.Equal(new[] { "page-0-result", "page-1-result", "page-2-result" }, observed); + + // No checkpoints were written: every step replayed from cache. + Assert.Empty(mockClient.CheckpointCalls); + } + + [Fact] + public async Task WrapAsync_NullInitialExecutionState_ReceivesDefaultPayload() + { + // No initial execution state at all. Same default-return branch in ExtractUserPayload. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:null-state" + }; + + OrderEvent? observed = null; + var output = await DurableFunction.WrapAsync( + async (evt, ctx) => + { + observed = evt; + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + new TestLambdaContext(), + _mockClient); + + Assert.Equal(InvocationStatus.Succeeded, output.Status); + Assert.Null(observed); + } + + // ────────────────────────────────────────────────────────────────────── + // IsTerminalCheckpointError classification (mirrors CheckpointError in + // aws-durable-execution-sdk-python): + // 4xx (except 429) → terminal (Failed envelope) + // 429 / 5xx / no status → transient (escapes to host for Lambda retry) + // Carve-out: InvalidParameterValueException "Invalid Checkpoint Token" → transient + // + // Driven through CheckpointDurableExecution: a workflow that succeeds a single Step + // forces the batcher to flush, which is wrapped by the try/catch in WrapAsyncCore. + // ────────────────────────────────────────────────────────────────────── + + public static IEnumerable TerminalCheckpointErrorCases() => new[] + { + new object[] { MakeServiceException("ResourceNotFoundException", HttpStatusCode.NotFound, "ARN not found") }, + new object[] { MakeServiceException("AccessDeniedException", HttpStatusCode.Forbidden, "denied") }, + new object[] { MakeServiceException("KMSAccessDeniedException", HttpStatusCode.BadRequest, "kms denied") }, + new object[] { MakeServiceException("ValidationException", HttpStatusCode.BadRequest, "bad input") }, + new object[] { MakeServiceException("InvalidParameterValueException", HttpStatusCode.BadRequest, "Some other parameter") }, + }; + + [Theory] + [MemberData(nameof(TerminalCheckpointErrorCases))] + public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) + { + var input = MakeCheckpointInput(); + var mockClient = new MockLambdaClient { CheckpointThrows = ex }; + + var output = await DurableFunction.WrapAsync( + SingleStepWorkflow, input, new TestLambdaContext(), mockClient); + + Assert.Equal(InvocationStatus.Failed, output.Status); + Assert.NotNull(output.Error); + Assert.Equal(ex.Message, output.Error!.ErrorMessage); + } + + public static IEnumerable TransientCheckpointErrorCases() => new[] + { + // 5xx + new object[] { MakeServiceException("InternalServerError", HttpStatusCode.InternalServerError, "boom") }, + new object[] { MakeServiceException("ServiceUnavailable", HttpStatusCode.ServiceUnavailable, "down") }, + // 429 + new object[] { MakeServiceException("TooManyRequestsException", (HttpStatusCode)429, "throttled") }, + // No status (network / SDK-internal). HttpStatusCode default (0) → classifier treats < 400 as transient. + new object[] { MakeServiceException("RequestTimeout", 0, "timeout") }, + // Carve-out: stale checkpoint token is transient. + new object[] { MakeServiceException("InvalidParameterValueException", HttpStatusCode.BadRequest, "Invalid Checkpoint Token: stale") }, + }; + + [Theory] + [MemberData(nameof(TransientCheckpointErrorCases))] + public async Task WrapAsync_CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) + { + var input = MakeCheckpointInput(); + var mockClient = new MockLambdaClient { CheckpointThrows = ex }; + + var thrown = await Assert.ThrowsAsync(ex.GetType(), () => + DurableFunction.WrapAsync( + SingleStepWorkflow, input, new TestLambdaContext(), mockClient)); + + Assert.Same(ex, thrown); + } + + [Fact] + public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() + { + // State hydration is OUTSIDE the IsTerminalCheckpointError try/catch — every + // GetExecutionStateAsync failure escapes for Lambda retry, matching Python's + // GetExecutionStateError (an InvocationError). Use a 4xx that *would* be terminal + // if it came from a checkpoint flush to prove the path isn't classified. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:hydrate-fail", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-1\"}" } + } + }, + NextMarker = "page-1" // force the hydration loop to run + } + }; + var ex = MakeServiceException("ResourceNotFoundException", HttpStatusCode.NotFound, "ARN gone"); + var mockClient = new MockLambdaClient { GetExecutionStateThrows = ex }; + + var thrown = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + MyWorkflow, input, new TestLambdaContext(), mockClient)); + + Assert.Same(ex, thrown); + } + + private static AmazonServiceException MakeServiceException(string code, HttpStatusCode status, string message) + { + return new AmazonServiceException(message, innerException: null, ErrorType.Unknown, code, requestId: "req-1", statusCode: status); + } + + private static DurableExecutionInvocationInput MakeCheckpointInput() => new() + { + DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:checkpoint-fail", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"order-1\"}" } + } + } + } + }; + + private static async Task SingleStepWorkflow(OrderEvent input, IDurableContext context) + { + // One step succeed → forces a checkpoint flush, which the mock fails. + await context.StepAsync(async (_) => { await Task.CompletedTask; return "ok"; }, name: "s1"); + return new OrderResult { Status = "done" }; + } + + private static async Task MyWorkflow(OrderEvent input, IDurableContext context) + { + var validation = await context.StepAsync( + async (_) => { await Task.CompletedTask; return new ValidationResult { IsValid = true }; }, + name: "validate"); + + await context.WaitAsync(TimeSpan.FromSeconds(30), name: "delay"); + + return new OrderResult { Status = "approved", OrderId = input.OrderId }; + } + + private class OrderEvent + { + public string? OrderId { get; set; } + } + + private class OrderResult + { + public string? Status { get; set; } + public string? OrderId { get; set; } + } + + private class ValidationResult + { + public bool IsValid { get; set; } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/EnumsTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/EnumsTests.cs new file mode 100644 index 000000000..1626f118a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/EnumsTests.cs @@ -0,0 +1,39 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class EnumsTests +{ + [Fact] + public void InvocationStatus_HasExpectedValues() + { + Assert.Equal(0, (int)InvocationStatus.Succeeded); + Assert.Equal(1, (int)InvocationStatus.Failed); + Assert.Equal(2, (int)InvocationStatus.Pending); + } + + [Fact] + public void OperationTypes_HasExpectedConstants() + { + Assert.Equal("STEP", OperationTypes.Step); + Assert.Equal("WAIT", OperationTypes.Wait); + Assert.Equal("CALLBACK", OperationTypes.Callback); + Assert.Equal("CHAINED_INVOKE", OperationTypes.ChainedInvoke); + Assert.Equal("CONTEXT", OperationTypes.Context); + Assert.Equal("EXECUTION", OperationTypes.Execution); + } + + [Fact] + public void OperationStatuses_HasExpectedConstants() + { + Assert.Equal("STARTED", OperationStatuses.Started); + Assert.Equal("SUCCEEDED", OperationStatuses.Succeeded); + Assert.Equal("FAILED", OperationStatuses.Failed); + Assert.Equal("PENDING", OperationStatuses.Pending); + Assert.Equal("CANCELLED", OperationStatuses.Cancelled); + Assert.Equal("READY", OperationStatuses.Ready); + Assert.Equal("STOPPED", OperationStatuses.Stopped); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExceptionsTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExceptionsTests.cs new file mode 100644 index 000000000..7105849bb --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExceptionsTests.cs @@ -0,0 +1,68 @@ +using Amazon.Lambda.DurableExecution; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class ExceptionsTests +{ + [Fact] + public void DurableExecutionException_IsBaseException() + { + var ex = new DurableExecutionException("test error"); + Assert.IsAssignableFrom(ex); + Assert.Equal("test error", ex.Message); + } + + [Fact] + public void DurableExecutionException_WrapsInnerException() + { + var inner = new InvalidOperationException("inner"); + var ex = new DurableExecutionException("outer", inner); + Assert.Same(inner, ex.InnerException); + } + + [Fact] + public void DurableExecutionException_ParameterlessCtor() + { + var ex = new DurableExecutionException(); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void StepException_ParameterlessCtor() + { + var ex = new StepException(); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void StepException_MessageOnlyCtor() + { + var ex = new StepException("step blew up"); + Assert.Equal("step blew up", ex.Message); + } + + [Fact] + public void StepException_WithInnerException() + { + var inner = new InvalidOperationException("inner"); + var ex = new StepException("wrapped", inner); + Assert.Same(inner, ex.InnerException); + } + + [Fact] + public void StepException_HasErrorProperties() + { + var ex = new StepException("step failed") + { + ErrorType = "System.TimeoutException", + ErrorData = "operation timed out", + OriginalStackTrace = new[] { "at Foo.Bar()", "at Baz.Qux()" } + }; + + Assert.IsAssignableFrom(ex); + Assert.Equal("System.TimeoutException", ex.ErrorType); + Assert.Equal("operation timed out", ex.ErrorData); + Assert.Equal(2, ex.OriginalStackTrace!.Count); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs new file mode 100644 index 000000000..3aad57e2d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs @@ -0,0 +1,165 @@ +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; +using Operation = Amazon.Lambda.DurableExecution.Internal.Operation; +using StepDetails = Amazon.Lambda.DurableExecution.Internal.StepDetails; +namespace Amazon.Lambda.DurableExecution.Tests; + +public class ExecutionStateTests +{ + [Fact] + public void LoadFromCheckpoint_NullState_EntersExecutionMode() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + + Assert.Equal(ExecutionMode.Execution, state.Mode); + Assert.Equal(0, state.CheckpointedOperationCount); + } + + [Fact] + public void LoadFromCheckpoint_EmptyOperations_EntersExecutionMode() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List() }); + + Assert.Equal(ExecutionMode.Execution, state.Mode); + Assert.Equal(0, state.CheckpointedOperationCount); + } + + [Fact] + public void LoadFromCheckpoint_WithOperations_StaysInReplayMode() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "0-fetch_user", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "{\"name\":\"Alice\"}" } + } + } + }); + + Assert.Equal(ExecutionMode.Replay, state.Mode); + Assert.Equal(1, state.CheckpointedOperationCount); + } + + [Fact] + public void GetOperation_ReturnsCheckpointedRecord() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "0-validate", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "true" } + } + } + }); + + var op = state.GetOperation("0-validate"); + Assert.NotNull(op); + Assert.Equal(OperationStatuses.Succeeded, op!.Status); + Assert.Equal("true", op.StepDetails?.Result); + } + + [Fact] + public void GetOperation_ReturnsNull_WhenNotFound() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + + var op = state.GetOperation("0-nonexistent"); + Assert.Null(op); + } + + [Fact] + public void HasOperation_ReturnsTrueForExisting() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "0-step_a", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded + } + } + }); + + Assert.True(state.HasOperation("0-step_a")); + Assert.False(state.HasOperation("1-step_b")); + } + + [Fact] + public void EnterExecutionMode_FlipsModeAndIsIdempotent() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "0", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded + } + } + }); + + Assert.Equal(ExecutionMode.Replay, state.Mode); + + state.EnterExecutionMode(); + Assert.Equal(ExecutionMode.Execution, state.Mode); + + state.EnterExecutionMode(); + Assert.Equal(ExecutionMode.Execution, state.Mode); + } + + [Fact] + public void GetOperation_ReturnsLatestRecord_WhenIdAppearsMultipleTimes() + { + // Wire format: when the service replays an envelope it includes the + // most recent record per ID. Java/Python/JS reference SDKs all key by + // ID alone and rely on the service to provide the authoritative record. + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "0-payment", + Type = OperationTypes.Step, + Status = OperationStatuses.Started + }, + new() + { + Id = "0-payment", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + StepDetails = new StepDetails { Result = "\"paid\"" } + } + } + }); + + var op = state.GetOperation("0-payment"); + Assert.NotNull(op); + Assert.Equal(OperationStatuses.Succeeded, op!.Status); + Assert.Equal("\"paid\"", op.StepDetails?.Result); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/LambdaDurableServiceClientTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/LambdaDurableServiceClientTests.cs new file mode 100644 index 000000000..2326f8544 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/LambdaDurableServiceClientTests.cs @@ -0,0 +1,202 @@ +using Amazon.Lambda.DurableExecution.Services; +using Amazon.Lambda.Model; +using SdkErrorObject = Amazon.Lambda.Model.ErrorObject; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class LambdaDurableServiceClientTests +{ + [Fact] + public async Task CheckpointAsync_EmptyOperations_NoApiCallReturnsToken() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + var token = await client.CheckpointAsync( + "arn:aws:lambda:us-east-1:123:durable-execution:e1", + "input-token", + Array.Empty()); + + Assert.Equal("input-token", token); + Assert.Empty(mockClient.CheckpointCalls); + } + + [Fact] + public async Task CheckpointAsync_NullCheckpointToken_SendsEmptyString() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + await client.CheckpointAsync( + "arn:aws:lambda:us-east-1:123:durable-execution:e1", + checkpointToken: null, + new[] + { + new OperationUpdate + { + Id = "0-step", + Type = "STEP", + Action = "SUCCEED", + SubType = "Step", + Name = "do_thing", + Payload = "\"ok\"" + } + }); + + var call = Assert.Single(mockClient.CheckpointCalls); + Assert.Equal("", call.CheckpointToken); + } + + [Fact] + public async Task CheckpointAsync_StepWithError_PropagatesError() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + await client.CheckpointAsync( + "arn:aws:lambda:us-east-1:123:durable-execution:e1", + "tok", + new[] + { + new OperationUpdate + { + Id = "0-bad", + Type = "STEP", + Action = "FAIL", + SubType = "Step", + Name = "bad", + Error = new SdkErrorObject + { + ErrorType = "System.TimeoutException", + ErrorMessage = "timed out", + ErrorData = "{\"detail\":\"x\"}", + StackTrace = new List { "at A.B()", "at C.D()" } + } + } + }); + + var call = Assert.Single(mockClient.CheckpointCalls); + var update = Assert.Single(call.Updates); + Assert.Equal("STEP", update.Type); + Assert.Equal("FAIL", update.Action); + Assert.NotNull(update.Error); + Assert.Equal("System.TimeoutException", update.Error.ErrorType); + Assert.Equal("timed out", update.Error.ErrorMessage); + Assert.Equal("{\"detail\":\"x\"}", update.Error.ErrorData); + Assert.Equal(2, update.Error.StackTrace.Count); + } + + [Fact] + public async Task CheckpointAsync_WaitWithOptions_PropagatesWaitOptions() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + await client.CheckpointAsync( + "arn", + "tok", + new[] + { + new OperationUpdate + { + Id = "0-wait", + Type = "WAIT", + Action = "START", + SubType = "Wait", + Name = "delay", + WaitOptions = new WaitOptions { WaitSeconds = 45 } + } + }); + + var update = mockClient.CheckpointCalls[0].Updates[0]; + Assert.NotNull(update.WaitOptions); + Assert.Equal(45, update.WaitOptions.WaitSeconds); + } + + [Fact] + public async Task CheckpointAsync_ParentIdAndPayload_ArePropagated() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + await client.CheckpointAsync( + "arn", + "tok", + new[] + { + new OperationUpdate + { + Id = "child-1", + ParentId = "parent-0", + Type = "STEP", + Action = "SUCCEED", + SubType = "Step", + Payload = "{\"a\":1}" + } + }); + + var update = mockClient.CheckpointCalls[0].Updates[0]; + Assert.Equal("parent-0", update.ParentId); + Assert.Equal("{\"a\":1}", update.Payload); + } + + [Fact] + public async Task CheckpointAsync_MultipleUpdates_AllForwarded() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + await client.CheckpointAsync( + "arn", + "tok", + new[] + { + new OperationUpdate + { + Id = "0-step", + Type = "STEP", + Action = "SUCCEED", + SubType = "Step", + Name = "validate" + }, + new OperationUpdate + { + Id = "1-wait", + Type = "WAIT", + Action = "START", + SubType = "Wait", + Name = "delay", + WaitOptions = new WaitOptions { WaitSeconds = 30 } + } + }); + + var call = Assert.Single(mockClient.CheckpointCalls); + Assert.Equal(2, call.Updates.Count); + Assert.Equal("STEP", call.Updates[0].Type); + Assert.Equal("WAIT", call.Updates[1].Type); + } + + [Fact] + public async Task CheckpointAsync_ReturnsNewToken() + { + var mockClient = new MockLambdaClient(); + var client = new LambdaDurableServiceClient(mockClient); + + var newToken = await client.CheckpointAsync( + "arn", + "old-token", + new[] + { + new OperationUpdate + { + Id = "0-x", + Type = "STEP", + Action = "SUCCEED" + } + }); + + // MockLambdaClient returns "token-1", "token-2", etc. + Assert.Equal("token-1", newToken); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/MockLambdaClient.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/MockLambdaClient.cs new file mode 100644 index 000000000..8df98a67d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/MockLambdaClient.cs @@ -0,0 +1,65 @@ +using Amazon.Lambda; +using Amazon.Lambda.Model; +using Amazon.Runtime; + +namespace Amazon.Lambda.DurableExecution.Tests; + +/// +/// A mock that subclasses AmazonLambdaClient and overrides CheckpointDurableExecutionAsync +/// to avoid real API calls. Records checkpoint requests for test assertions. +/// +internal class MockLambdaClient : AmazonLambdaClient +{ + public List CheckpointCalls { get; } = new(); + public List GetExecutionStateCalls { get; } = new(); + + /// + /// Optional handler for calls. Tests + /// that exercise the paginated-state path can set this to control the response + /// for each page. + /// + public Func? GetExecutionStateHandler { get; set; } + + private int _tokenCounter; + + public MockLambdaClient() : base("fake-access-key", "fake-secret-key", Amazon.RegionEndpoint.USEast1) { } + + /// + /// Optional exception thrown by . Tests + /// that exercise checkpoint-error classification can set this to inject a specific + /// SDK exception on the orchestration-path drain. + /// + public Exception? CheckpointThrows { get; set; } + + /// + /// Optional exception thrown by . Tests + /// that exercise hydration-error classification can set this to inject a specific + /// SDK exception on the initial state-fetch path. + /// + public Exception? GetExecutionStateThrows { get; set; } + + public override Task CheckpointDurableExecutionAsync( + CheckpointDurableExecutionRequest request, + CancellationToken cancellationToken = default) + { + CheckpointCalls.Add(request); + if (CheckpointThrows != null) throw CheckpointThrows; + return Task.FromResult(new CheckpointDurableExecutionResponse + { + CheckpointToken = $"token-{++_tokenCounter}" + }); + } + + public override Task GetDurableExecutionStateAsync( + GetDurableExecutionStateRequest request, + CancellationToken cancellationToken = default) + { + GetExecutionStateCalls.Add(request); + if (GetExecutionStateThrows != null) throw GetExecutionStateThrows; + if (GetExecutionStateHandler != null) + { + return Task.FromResult(GetExecutionStateHandler(request)); + } + return Task.FromResult(new GetDurableExecutionStateResponse()); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ModelsTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ModelsTests.cs new file mode 100644 index 000000000..2b7d3489e --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ModelsTests.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class ModelsTests +{ + [Fact] + public void Operation_PropertiesAssignable() + { + var op = new Operation + { + Id = "op-1", + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + Name = "fetch_user", + StepDetails = new StepDetails { Result = "{\"name\":\"Alice\"}" } + }; + + Assert.Equal("op-1", op.Id); + Assert.Equal(OperationTypes.Step, op.Type); + Assert.Equal(OperationStatuses.Succeeded, op.Status); + Assert.Equal("fetch_user", op.Name); + Assert.Equal("{\"name\":\"Alice\"}", op.StepDetails?.Result); + } + + [Fact] + public void Operation_WaitWithScheduledEndTimestamp() + { + var op = new Operation + { + Id = "op-2", + Type = OperationTypes.Wait, + Status = OperationStatuses.Pending, + Name = "cooldown", + WaitDetails = new WaitDetails + { + ScheduledEndTimestamp = 1767268830000L // 2026-01-01T12:00:30Z in ms + } + }; + + Assert.Equal(OperationTypes.Wait, op.Type); + Assert.Equal(1767268830000L, op.WaitDetails?.ScheduledEndTimestamp); + } + + [Fact] + public void ErrorObject_FromException() + { + var ex = new InvalidOperationException("something went wrong"); + var error = ErrorObject.FromException(ex); + + Assert.Equal("System.InvalidOperationException", error.ErrorType); + Assert.Equal("something went wrong", error.ErrorMessage); + } + + [Fact] + public void ErrorObject_RoundTripSerialization() + { + var error = new ErrorObject + { + ErrorType = "System.TimeoutException", + ErrorMessage = "timed out", + StackTrace = new[] { "at Foo.Bar()", "at Baz.Qux()" }, + ErrorData = "{\"key\":\"value\"}" + }; + + var json = JsonSerializer.Serialize(error); + var deserialized = JsonSerializer.Deserialize(json)!; + + Assert.Equal("System.TimeoutException", deserialized.ErrorType); + Assert.Equal("timed out", deserialized.ErrorMessage); + Assert.Equal(2, deserialized.StackTrace!.Count); + Assert.Equal("{\"key\":\"value\"}", deserialized.ErrorData); + } + + [Fact] + public void DurableExecutionInvocationInput_Deserialization() + { + var json = """ + { + "DurableExecutionArn": "arn:aws:lambda:us-east-1:123:durable-execution:abc", + "CheckpointToken": "token-1", + "InitialExecutionState": { + "Operations": [ + { + "Id": "exec-1", + "Type": "EXECUTION", + "Status": "STARTED", + "ExecutionDetails": { + "InputPayload": "{\"orderId\":\"order-123\",\"amount\":99.99}" + } + }, + { + "Id": "op-1", + "Type": "STEP", + "Status": "SUCCEEDED", + "Name": "validate", + "StepDetails": { + "Result": "true" + } + } + ] + } + } + """; + + var input = JsonSerializer.Deserialize(json)!; + + Assert.Equal("arn:aws:lambda:us-east-1:123:durable-execution:abc", input.DurableExecutionArn); + Assert.Equal("token-1", input.CheckpointToken); + Assert.NotNull(input.InitialExecutionState); + Assert.Equal(2, input.InitialExecutionState!.Operations!.Count); + + var stepOp = input.InitialExecutionState.Operations![1]; + Assert.Equal("op-1", stepOp.Id); + Assert.Equal(OperationTypes.Step, stepOp.Type); + Assert.Equal("true", stepOp.StepDetails?.Result); + + // The EXECUTION operation carries the user payload in ExecutionDetails.InputPayload. + var execOp = input.InitialExecutionState.Operations[0]; + Assert.Equal(OperationTypes.Execution, execOp.Type); + var payload = JsonSerializer.Deserialize(execOp.ExecutionDetails!.InputPayload!); + Assert.Equal("order-123", payload!.OrderId); + Assert.Equal(99.99m, payload.Amount); + } + + [Fact] + public void DurableExecutionInvocationInput_NoExecutionOp_HasNullPayload() + { + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:test" + }; + + // No InitialExecutionState means no EXECUTION operation and thus no user payload + Assert.Null(input.InitialExecutionState); + } + + [Fact] + public void DurableExecutionInvocationOutput_Succeeded() + { + var output = new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Succeeded, + Result = "{\"status\":\"approved\"}" + }; + + var json = JsonSerializer.Serialize(output); + var deserialized = JsonSerializer.Deserialize(json)!; + + Assert.Equal(InvocationStatus.Succeeded, deserialized.Status); + Assert.Equal("{\"status\":\"approved\"}", deserialized.Result); + } + + [Fact] + public void DurableExecutionInvocationOutput_Failed() + { + var output = new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Failed, + Error = new ErrorObject + { + ErrorMessage = "step failed", + ErrorType = "StepException" + } + }; + + var json = JsonSerializer.Serialize(output); + var deserialized = JsonSerializer.Deserialize(json)!; + + Assert.Equal(InvocationStatus.Failed, deserialized.Status); + Assert.NotNull(deserialized.Error); + Assert.Equal("step failed", deserialized.Error!.ErrorMessage); + Assert.Equal("StepException", deserialized.Error.ErrorType); + } + + [Fact] + public void DurableExecutionInvocationOutput_Pending() + { + var output = new DurableExecutionInvocationOutput + { + Status = InvocationStatus.Pending + }; + + var json = JsonSerializer.Serialize(output); + var deserialized = JsonSerializer.Deserialize(json)!; + + Assert.Equal(InvocationStatus.Pending, deserialized.Status); + Assert.Null(deserialized.Result); + Assert.Null(deserialized.Error); + } + + private class TestOrderEvent + { + [System.Text.Json.Serialization.JsonPropertyName("orderId")] + public string? OrderId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("amount")] + public decimal Amount { get; set; } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs new file mode 100644 index 000000000..6eb63551b --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs @@ -0,0 +1,100 @@ +using System.Security.Cryptography; +using System.Text; +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class OperationIdGeneratorTests +{ + private static string Sha256Hex(string input) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + var sb = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) sb.Append(b.ToString("x2")); + return sb.ToString(); + } + + [Fact] + public void NextId_ProducesSha256OfPositionString_StartingAtOne() + { + var gen = new OperationIdGenerator(); + Assert.Equal(Sha256Hex("1"), gen.NextId()); + Assert.Equal(Sha256Hex("2"), gen.NextId()); + Assert.Equal(Sha256Hex("3"), gen.NextId()); + } + + [Fact] + public void NextId_NameIsNotPartOfId() + { + // Name must not influence the deterministic ID — replays must still + // correlate after a step is renamed. The reference SDKs (Java/JS/Python) + // all keep Name in a separate field on OperationUpdate. + var gen = new OperationIdGenerator(); + Assert.Equal(Sha256Hex("1"), gen.NextId()); + Assert.Equal(Sha256Hex("2"), gen.NextId()); + } + + [Fact] + public void HashOperationId_IsStable() + { + Assert.Equal(Sha256Hex("hello"), OperationIdGenerator.HashOperationId("hello")); + Assert.Equal(Sha256Hex("1"), OperationIdGenerator.HashOperationId("1")); + } + + [Fact] + public void ChildGenerator_PrefixesPositionWithParentHash() + { + var gen = new OperationIdGenerator(); + var parentId = gen.NextId(); + var child = gen.CreateChild(parentId); + + Assert.Equal(Sha256Hex(parentId + "-1"), child.NextId()); + Assert.Equal(Sha256Hex(parentId + "-2"), child.NextId()); + } + + [Fact] + public void ChildGenerator_ParentIdProperty() + { + var gen = new OperationIdGenerator(); + Assert.Null(gen.ParentId); + + var child = new OperationIdGenerator("op-5"); + Assert.Equal("op-5", child.ParentId); + } + + [Fact] + public void MultipleChildren_IndependentCounters() + { + var child1 = new OperationIdGenerator("parent-1"); + var child2 = new OperationIdGenerator("parent-2"); + + Assert.Equal(Sha256Hex("parent-1-1"), child1.NextId()); + Assert.Equal(Sha256Hex("parent-2-1"), child2.NextId()); + Assert.Equal(Sha256Hex("parent-1-2"), child1.NextId()); + Assert.Equal(Sha256Hex("parent-2-2"), child2.NextId()); + } + + [Fact] + public void Deterministic_SameSequenceOnReplay() + { + var gen1 = new OperationIdGenerator(); + var ids1 = new[] { gen1.NextId(), gen1.NextId(), gen1.NextId() }; + + var gen2 = new OperationIdGenerator(); + var ids2 = new[] { gen2.NextId(), gen2.NextId(), gen2.NextId() }; + + Assert.Equal(ids1, ids2); + } + + [Fact] + public void Reset_RewindsCounter() + { + var gen = new OperationIdGenerator(); + gen.NextId(); + gen.NextId(); + gen.Reset(); + Assert.Equal(Sha256Hex("1"), gen.NextId()); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/RecordingBatcher.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/RecordingBatcher.cs new file mode 100644 index 000000000..8fe7b6d6d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/RecordingBatcher.cs @@ -0,0 +1,51 @@ +using Amazon.Lambda.DurableExecution.Internal; +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Tests; + +/// +/// Test helper: a that records every flushed +/// update without making any network calls. Tests construct one of these in +/// place of a real batcher to inspect what would have been sent to the service. +/// +internal sealed class RecordingBatcher +{ + private readonly List _flushed = new(); + private readonly List _flushBatchSizes = new(); + private readonly object _lock = new(); + + public CheckpointBatcher Batcher { get; } + + public RecordingBatcher(CheckpointBatcherConfig? config = null) + { + Batcher = new CheckpointBatcher("test-token", Flush, config); + } + + /// + /// Cumulative list of every update that has been flushed, in order. + /// + public IReadOnlyList Flushed + { + get { lock (_lock) return _flushed.ToArray(); } + } + + /// + /// One entry per batch flushed, recording the batch size. With + /// = Zero (default), + /// every produces one batch. + /// + public IReadOnlyList FlushBatchSizes + { + get { lock (_lock) return _flushBatchSizes.ToArray(); } + } + + private Task Flush(string? token, IReadOnlyList ops, CancellationToken ct) + { + lock (_lock) + { + _flushed.AddRange(ops); + _flushBatchSizes.Add(ops.Count); + } + return Task.FromResult(token); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/TerminationManagerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/TerminationManagerTests.cs new file mode 100644 index 000000000..a12ff4a6c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/TerminationManagerTests.cs @@ -0,0 +1,88 @@ +using Amazon.Lambda.DurableExecution.Internal; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +public class TerminationManagerTests +{ + [Fact] + public async Task Terminate_ResolvesTerminationTask() + { + var manager = new TerminationManager(); + Assert.False(manager.IsTerminated); + + manager.Terminate(TerminationReason.WaitScheduled, "wait pending"); + + Assert.True(manager.IsTerminated); + var result = await manager.TerminationTask; + Assert.Equal(TerminationReason.WaitScheduled, result.Reason); + Assert.Equal("wait pending", result.Message); + } + + [Fact] + public void Terminate_OnlyFirstCallWins() + { + var manager = new TerminationManager(); + + var first = manager.Terminate(TerminationReason.WaitScheduled, "first"); + var second = manager.Terminate(TerminationReason.CallbackPending, "second"); + + Assert.True(first); + Assert.False(second); + } + + [Fact] + public async Task Terminate_FirstReasonIsPreserved() + { + var manager = new TerminationManager(); + + manager.Terminate(TerminationReason.CallbackPending, "callback"); + manager.Terminate(TerminationReason.WaitScheduled, "wait"); + + var result = await manager.TerminationTask; + Assert.Equal(TerminationReason.CallbackPending, result.Reason); + Assert.Equal("callback", result.Message); + } + + [Fact] + public async Task Terminate_WithException() + { + var manager = new TerminationManager(); + var ex = new Exception("checkpoint failed"); + + manager.Terminate(TerminationReason.CheckpointFailed, "error", ex); + + var result = await manager.TerminationTask; + Assert.Equal(TerminationReason.CheckpointFailed, result.Reason); + Assert.Same(ex, result.Exception); + } + + [Fact] + public async Task TerminationTask_WinsRaceAgainstNeverCompletingTask() + { + var manager = new TerminationManager(); + var neverCompletes = new TaskCompletionSource().Task; + + manager.Terminate(TerminationReason.WaitScheduled); + + var winner = await Task.WhenAny(neverCompletes, manager.TerminationTask); + Assert.Same(manager.TerminationTask, winner); + } + + [Fact] + public async Task ConcurrentTerminate_OnlyOneSucceeds() + { + var manager = new TerminationManager(); + var results = new bool[10]; + + var tasks = Enumerable.Range(0, 10).Select(i => Task.Run(() => + { + results[i] = manager.Terminate(TerminationReason.WaitScheduled, $"caller-{i}"); + })); + + await Task.WhenAll(tasks); + + Assert.Equal(1, results.Count(r => r)); + Assert.True(manager.IsTerminated); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs new file mode 100644 index 000000000..7ac6df052 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Xunit; + +namespace Amazon.Lambda.DurableExecution.Tests; + +/// +/// Direct tests for UpperSnakeCaseEnumConverter via a sample enum, exercising +/// every branch (Read with multi-word value, Read with single word, Read with +/// null/unparsable, plus the Write path for outbound serialization). +/// +public class UpperSnakeCaseEnumConverterTests +{ + public enum Sample + { + None, + FooBar, + BazQuxQuux + } + + public class Holder + { + [JsonConverter(typeof(UpperSnakeCaseEnumConverter))] + public Sample Value { get; set; } + } + + [Theory] + [InlineData("\"FOO_BAR\"", Sample.FooBar)] + [InlineData("\"BAZ_QUX_QUUX\"", Sample.BazQuxQuux)] + [InlineData("\"NONE\"", Sample.None)] + public void Read_UpperSnakeCase_ReturnsExpectedEnum(string json, Sample expected) + { + var holder = JsonSerializer.Deserialize($"{{\"Value\":{json}}}")!; + Assert.Equal(expected, holder.Value); + } + + [Fact] + public void Read_NullValue_ReturnsDefault() + { + var holder = JsonSerializer.Deserialize("{\"Value\":null}")!; + Assert.Equal(Sample.None, holder.Value); + } + + [Fact] + public void Read_AlreadyPascalCase_ParsesCaseInsensitively() + { + // The converter first tries snake→pascal, then a raw case-insensitive parse. + // A camel-case input like "fooBar" hits the fallback path. + var holder = JsonSerializer.Deserialize("{\"Value\":\"fooBar\"}")!; + Assert.Equal(Sample.FooBar, holder.Value); + } + + [Fact] + public void Read_UnparsableValue_ThrowsJsonException() + { + // Unknown wire values must surface as JsonException rather than + // silently coercing to default(T) — otherwise an unrecognized + // service status would be indistinguishable from the zero value. + Assert.Throws(() => + JsonSerializer.Deserialize("{\"Value\":\"NOT_A_REAL_VALUE\"}")); + } + + [Fact] + public void Write_PascalCase_EmitsUpperSnake() + { + var json = JsonSerializer.Serialize(new Holder { Value = Sample.FooBar }); + Assert.Contains("\"FOO_BAR\"", json); + } + + [Fact] + public void Write_MultiWord_EmitsUpperSnake() + { + var json = JsonSerializer.Serialize(new Holder { Value = Sample.BazQuxQuux }); + Assert.Contains("\"BAZ_QUX_QUUX\"", json); + } + + [Fact] + public void Write_SingleWord_EmitsUpperWithoutUnderscores() + { + var json = JsonSerializer.Serialize(new Holder { Value = Sample.None }); + Assert.Contains("\"NONE\"", json); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.runsettings b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.runsettings new file mode 100644 index 000000000..6c38b1258 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.runsettings @@ -0,0 +1,15 @@ + + + + + + + cobertura + [Amazon.Lambda.DurableExecution]* + [Amazon.Lambda.DurableExecution.Tests]* + GeneratedCodeAttribute + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.sh b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.sh new file mode 100644 index 000000000..b953bd07e --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/coverage.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -e +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +PROJ="$HERE/Amazon.Lambda.DurableExecution.Tests.csproj" +OUT="$HERE/TestResults" + +rm -rf "$OUT" +dotnet test "$PROJ" -c Release \ + --collect:"XPlat Code Coverage" \ + --settings "$HERE/coverage.runsettings" \ + --results-directory "$OUT" + +REPORT_FILE=$(find "$OUT" -name "coverage.cobertura.xml" -type f | head -1) +if [ -z "$REPORT_FILE" ]; then + echo "No coverage report found under $OUT" + exit 1 +fi + +reportgenerator \ + "-reports:$REPORT_FILE" \ + "-targetdir:$OUT/report" \ + "-reporttypes:Html;TextSummary" + +echo +echo "==================== Coverage Summary ====================" +cat "$OUT/report/Summary.txt" +echo "==========================================================" +echo "Full HTML report: $OUT/report/index.html" From 8b853ed51c26677f3aa203f23914e4b6c93f0f99 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 14 May 2026 13:41:15 -0400 Subject: [PATCH 02/16] Track replay state per operation rather than via a global flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the Python / Java / JavaScript reference SDKs' replay-mode model: the workflow is "replaying" iff it has not yet revisited every checkpointed completed user-replayable operation. A single global flag flipped on the first fresh op (the prior model) misclassified workflow- body code that runs before the first step and would not generalize to Map/Parallel/Callback later. ExecutionState changes: - Replace `Mode`/`ExecutionMode`/`EnterExecutionMode()` with `IsReplaying` + `TrackReplay(operationId)`. - Initial replay decision: any non-EXECUTION op present means we're replaying. The service always sends an EXECUTION-type op carrying the input payload — that's bookkeeping, not user history, so it does not count toward replay (matches Python execution.py:258, Java ExecutionManager:81, JS execution-context.ts:62). - TrackReplay flips IsReplaying false once every checkpointed terminal- status non-EXECUTION op has been visited. Terminal set matches Python's: SUCCEEDED, FAILED, CANCELLED, STOPPED. Operation changes: - DurableOperation.ExecuteAsync calls TrackReplay(OperationId) at the top, so every operation participates in visit accounting without each subclass needing to remember. - StepOperation/WaitOperation drop their manual EnterExecutionMode calls. Tests: - ExecutionStateTests rewritten around IsReplaying/TrackReplay, including pinning regressions: only-EXECUTION-op ⇒ NotReplaying, all-visited ⇒ flips out of replay, PENDING ops do not block transition, idempotency. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/DurableOperation.cs | 4 + .../Internal/ExecutionState.cs | 105 +++++++---- .../Internal/StepOperation.cs | 6 +- .../Internal/WaitOperation.cs | 2 - .../ExecutionStateTests.cs | 168 ++++++++++++------ 5 files changed, 195 insertions(+), 90 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs index e7734abf9..907d6e128 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableOperation.cs @@ -44,6 +44,10 @@ public Task ExecuteAsync(CancellationToken cancellationToken) { State.ValidateReplayConsistency(OperationId, OperationType, Name); + // Record that the workflow has reached this op. If every completed + // checkpointed op has now been visited, the state flips out of replay. + State.TrackReplay(OperationId); + var existing = State.GetOperation(OperationId); return existing == null ? StartAsync(cancellationToken) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs index 5ee690be0..606614621 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs @@ -1,43 +1,50 @@ namespace Amazon.Lambda.DurableExecution.Internal; /// -/// Replay state of the current invocation. -/// -internal enum ExecutionMode -{ - /// Re-deriving prior operations from checkpointed state. - Replay, - /// Executing fresh code that hasn't been checkpointed before. - Execution -} - -/// -/// In-memory store of the operations replayed from . -/// Read-only after load (apart from ); outbound -/// checkpoints are owned by . +/// In-memory store of the operations replayed from +/// plus replay-mode tracking. Outbound checkpoints are owned by +/// ; this type is the inbound side only. /// +/// +/// Replay tracking mirrors the Python / Java / JavaScript reference SDKs: +/// +/// At construction the workflow is "replaying" iff any user-replayable +/// op is present. The service always sends one EXECUTION-type op +/// carrying the input payload — that's bookkeeping, not user history, +/// so it doesn't count. +/// is called by every DurableOperation.ExecuteAsync +/// at the top of the call. Once every checkpointed completed +/// non-EXECUTION op has been visited, the workflow has caught up +/// to the replay frontier and flips to false +/// for the rest of the invocation. +/// +/// internal sealed class ExecutionState { private readonly Dictionary _operations = new(); - - public ExecutionMode Mode { get; private set; } = ExecutionMode.Replay; + private readonly HashSet _visitedOperations = new(); + private bool _isReplaying; public int CheckpointedOperationCount => _operations.Count; + /// + /// True when the workflow is re-deriving prior operations from checkpointed + /// state. False when running fresh (not-yet-checkpointed) code. + /// + public bool IsReplaying => _isReplaying; + public void LoadFromCheckpoint(InitialExecutionState? initialState) { - if (initialState?.Operations == null) + if (initialState?.Operations != null) { - Mode = ExecutionMode.Execution; - return; + AddOperations(initialState.Operations); } - AddOperations(initialState.Operations); - - if (_operations.Count == 0) - { - Mode = ExecutionMode.Execution; - } + // Only user-replayable ops put us into replay mode. The service-side + // EXECUTION op (input payload bookkeeping) is always present and must + // not count — see Python execution.py:258 / Java ExecutionManager:81 / + // JS execution-context.ts:62 for the same rule. + _isReplaying = HasReplayableOperations(); } public void AddOperations(IEnumerable operations) @@ -60,9 +67,36 @@ public void AddOperations(IEnumerable operations) return op; } + public bool HasOperation(string operationId) => _operations.ContainsKey(operationId); + + /// + /// Records that the workflow has reached . + /// Once every checkpointed completed non-EXECUTION op has been + /// visited the workflow has caught up to the replay frontier and + /// flips to false. Idempotent: calling more than + /// once with the same id has no additional effect. + /// + public void TrackReplay(string operationId) + { + if (!_isReplaying) return; + + _visitedOperations.Add(operationId); + + // Have we visited every completed non-EXECUTION op? If so, anything + // emitted from here on is fresh execution. + foreach (var op in _operations.Values) + { + if (op.Type == OperationTypes.Execution) continue; + if (!IsTerminalStatus(op.Status)) continue; + if (!_visitedOperations.Contains(op.Id!)) return; + } + + _isReplaying = false; + } + public void ValidateReplayConsistency(string operationId, string expectedType, string? expectedName) { - if (Mode != ExecutionMode.Replay) return; + if (!_isReplaying) return; if (!_operations.TryGetValue(operationId, out var op)) return; @@ -83,11 +117,18 @@ public void ValidateReplayConsistency(string operationId, string expectedType, s } } - public bool HasOperation(string operationId) => _operations.ContainsKey(operationId); + private bool HasReplayableOperations() + { + foreach (var op in _operations.Values) + { + if (op.Type != OperationTypes.Execution) return true; + } + return false; + } - /// - /// Transitions to . Called by an operation - /// that's about to run fresh (not-yet-checkpointed) code. Idempotent. - /// - public void EnterExecutionMode() => Mode = ExecutionMode.Execution; + private static bool IsTerminalStatus(string? status) => + status == OperationStatuses.Succeeded + || status == OperationStatuses.Failed + || status == OperationStatuses.Cancelled + || status == OperationStatuses.Stopped; } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs index d5084229b..2decdb309 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs @@ -50,10 +50,7 @@ public StepOperation( protected override string OperationType => OperationTypes.Step; protected override Task StartAsync(CancellationToken cancellationToken) - { - State.EnterExecutionMode(); - return ExecuteFunc(cancellationToken); - } + => ExecuteFunc(cancellationToken); protected override Task ReplayAsync(Operation existing, CancellationToken cancellationToken) { @@ -73,7 +70,6 @@ protected override Task ReplayAsync(Operation existing, CancellationToken can // STARTED/READY/PENDING from a prior invocation — no retry logic // in this commit, so fall through and execute fresh. (Future work // on retries will replace this default with explicit arms.) - State.EnterExecutionMode(); return ExecuteFunc(cancellationToken); } } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs index 4fb069bf3..59254827d 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs @@ -41,8 +41,6 @@ public WaitOperation( protected override async Task StartAsync(CancellationToken cancellationToken) { - State.EnterExecutionMode(); - // Sync-flush WAIT START before suspending — the service can't schedule // a timer for a checkpoint it hasn't received. await EnqueueAsync(new SdkOperationUpdate diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs index 3aad57e2d..6500879c1 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs @@ -7,127 +7,193 @@ namespace Amazon.Lambda.DurableExecution.Tests; public class ExecutionStateTests { + private const string ExecutionInputId = "exec-input"; + + private static Operation ExecutionInputOp(string id = ExecutionInputId) => new() + { + Id = id, + Type = OperationTypes.Execution, + Status = OperationStatuses.Started + }; + + private static Operation StepOp(string id, string status, string? name = null) => new() + { + Id = id, + Type = OperationTypes.Step, + Status = status, + Name = name, + StepDetails = new StepDetails { Result = "true" } + }; + [Fact] - public void LoadFromCheckpoint_NullState_EntersExecutionMode() + public void LoadFromCheckpoint_NullState_NotReplaying() { var state = new ExecutionState(); state.LoadFromCheckpoint(null); - Assert.Equal(ExecutionMode.Execution, state.Mode); + Assert.False(state.IsReplaying); Assert.Equal(0, state.CheckpointedOperationCount); } [Fact] - public void LoadFromCheckpoint_EmptyOperations_EntersExecutionMode() + public void LoadFromCheckpoint_EmptyOperations_NotReplaying() { var state = new ExecutionState(); state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List() }); - Assert.Equal(ExecutionMode.Execution, state.Mode); + Assert.False(state.IsReplaying); Assert.Equal(0, state.CheckpointedOperationCount); } [Fact] - public void LoadFromCheckpoint_WithOperations_StaysInReplayMode() + public void LoadFromCheckpoint_OnlyExecutionInputOp_NotReplaying() + { + // The service sends one EXECUTION-type op carrying the input payload + // even on the first invocation. That op is bookkeeping, not user + // history — it must not put us into replay mode. (Matches Python + // execution.py:258, Java ExecutionManager:81, JS execution-context.ts:62.) + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List { ExecutionInputOp() } + }); + + Assert.False(state.IsReplaying); + Assert.Equal(1, state.CheckpointedOperationCount); + } + + [Fact] + public void LoadFromCheckpoint_WithReplayableOperations_IsReplaying() { var state = new ExecutionState(); state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List { - new() - { - Id = "0-fetch_user", - Type = OperationTypes.Step, - Status = OperationStatuses.Succeeded, - StepDetails = new StepDetails { Result = "{\"name\":\"Alice\"}" } - } + ExecutionInputOp(), + StepOp("0-fetch_user", OperationStatuses.Succeeded) } }); - Assert.Equal(ExecutionMode.Replay, state.Mode); - Assert.Equal(1, state.CheckpointedOperationCount); + Assert.True(state.IsReplaying); + Assert.Equal(2, state.CheckpointedOperationCount); } [Fact] - public void GetOperation_ReturnsCheckpointedRecord() + public void TrackReplay_FlipsOutOfReplay_OnceAllCompletedOpsVisited() { var state = new ExecutionState(); state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List { - new() - { - Id = "0-validate", - Type = OperationTypes.Step, - Status = OperationStatuses.Succeeded, - StepDetails = new StepDetails { Result = "true" } - } + ExecutionInputOp(), + StepOp("0", OperationStatuses.Succeeded), + StepOp("1", OperationStatuses.Succeeded), } }); + Assert.True(state.IsReplaying); - var op = state.GetOperation("0-validate"); - Assert.NotNull(op); - Assert.Equal(OperationStatuses.Succeeded, op!.Status); - Assert.Equal("true", op.StepDetails?.Result); + state.TrackReplay("0"); + Assert.True(state.IsReplaying); // 1-of-2 completed ops visited + + state.TrackReplay("1"); + Assert.False(state.IsReplaying); // all completed ops visited → fresh } [Fact] - public void GetOperation_ReturnsNull_WhenNotFound() + public void TrackReplay_PendingOpDoesNotBlockTransition() { + // A PENDING op (e.g. retry timer waiting) is not "completed" in the + // checkpoint sense — once the workflow has visited every terminally- + // completed op the SDK treats subsequent code as fresh. Matches Python's + // {SUCCEEDED, FAILED, CANCELLED, STOPPED, TIMED_OUT} terminal set. var state = new ExecutionState(); - state.LoadFromCheckpoint(null); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List + { + ExecutionInputOp(), + StepOp("0", OperationStatuses.Succeeded), + StepOp("1", OperationStatuses.Pending), + } + }); + Assert.True(state.IsReplaying); - var op = state.GetOperation("0-nonexistent"); - Assert.Null(op); + state.TrackReplay("0"); + Assert.False(state.IsReplaying); } [Fact] - public void HasOperation_ReturnsTrueForExisting() + public void TrackReplay_IsIdempotent() { var state = new ExecutionState(); state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List { - new() - { - Id = "0-step_a", - Type = OperationTypes.Step, - Status = OperationStatuses.Succeeded - } + ExecutionInputOp(), + StepOp("0", OperationStatuses.Succeeded), } }); - Assert.True(state.HasOperation("0-step_a")); - Assert.False(state.HasOperation("1-step_b")); + state.TrackReplay("0"); + Assert.False(state.IsReplaying); + + // Second call is a no-op. + state.TrackReplay("0"); + Assert.False(state.IsReplaying); } [Fact] - public void EnterExecutionMode_FlipsModeAndIsIdempotent() + public void TrackReplay_NoOpWhenNotReplaying() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); + Assert.False(state.IsReplaying); + + state.TrackReplay("anything"); + Assert.False(state.IsReplaying); + } + + [Fact] + public void GetOperation_ReturnsCheckpointedRecord() { var state = new ExecutionState(); state.LoadFromCheckpoint(new InitialExecutionState { Operations = new List { - new() - { - Id = "0", - Type = OperationTypes.Step, - Status = OperationStatuses.Succeeded - } + StepOp("0-validate", OperationStatuses.Succeeded) } }); - Assert.Equal(ExecutionMode.Replay, state.Mode); + var op = state.GetOperation("0-validate"); + Assert.NotNull(op); + Assert.Equal(OperationStatuses.Succeeded, op!.Status); + } + + [Fact] + public void GetOperation_ReturnsNull_WhenNotFound() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(null); - state.EnterExecutionMode(); - Assert.Equal(ExecutionMode.Execution, state.Mode); + var op = state.GetOperation("0-nonexistent"); + Assert.Null(op); + } - state.EnterExecutionMode(); - Assert.Equal(ExecutionMode.Execution, state.Mode); + [Fact] + public void HasOperation_ReturnsTrueForExisting() + { + var state = new ExecutionState(); + state.LoadFromCheckpoint(new InitialExecutionState + { + Operations = new List { StepOp("0-step_a", OperationStatuses.Succeeded) } + }); + + Assert.True(state.HasOperation("0-step_a")); + Assert.False(state.HasOperation("1-step_b")); } [Fact] From d4d5d3d304cdc661514f9c81831ce5e09a6cfeb0 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 14 May 2026 14:00:50 -0400 Subject: [PATCH 03/16] Add to sln --- Libraries/Libraries.sln | 58 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index e42c40045..1bc34a173 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.5.11709.299 stable +VisualStudioVersion = 18.5.11709.299 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}" EndProject @@ -155,6 +155,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResponseStreamingFunctionHa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreStreamingApiGatewayTest", "test\Amazon.Lambda.RuntimeSupport.Tests\AspNetCoreStreamingApiGatewayTest\AspNetCoreStreamingApiGatewayTest.csproj", "{0768FA72-CF49-2B59-BC4C-E4CE579E5D93}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution", "src\Amazon.Lambda.DurableExecution\Amazon.Lambda.DurableExecution.csproj", "{9097B5A4-E100-47FD-A676-0B666A36FAFF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Tests", "test\Amazon.Lambda.DurableExecution.Tests\Amazon.Lambda.DurableExecution.Tests.csproj", "{57150BA6-3826-431F-8F58-B1D11FAFC5D4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.IntegrationTests", "test\Amazon.Lambda.DurableExecution.IntegrationTests\Amazon.Lambda.DurableExecution.IntegrationTests.csproj", "{CA132CAB-FF4F-4312-B3A3-66DE9D360F27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.AotPublishTest", "test\Amazon.Lambda.DurableExecution.AotPublishTest\Amazon.Lambda.DurableExecution.AotPublishTest.csproj", "{16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -969,6 +977,54 @@ Global {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x64.Build.0 = Release|Any CPU {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x86.ActiveCfg = Release|Any CPU {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x86.Build.0 = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|x64.Build.0 = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Debug|x86.Build.0 = Debug|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|Any CPU.Build.0 = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|x64.ActiveCfg = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|x64.Build.0 = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|x86.ActiveCfg = Release|Any CPU + {9097B5A4-E100-47FD-A676-0B666A36FAFF}.Release|x86.Build.0 = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|x64.ActiveCfg = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|x64.Build.0 = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|x86.ActiveCfg = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Debug|x86.Build.0 = Debug|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|Any CPU.Build.0 = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|x64.ActiveCfg = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|x64.Build.0 = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|x86.ActiveCfg = Release|Any CPU + {57150BA6-3826-431F-8F58-B1D11FAFC5D4}.Release|x86.Build.0 = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|x64.Build.0 = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Debug|x86.Build.0 = Debug|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|Any CPU.Build.0 = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|x64.ActiveCfg = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|x64.Build.0 = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|x86.ActiveCfg = Release|Any CPU + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27}.Release|x86.Build.0 = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|x64.Build.0 = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Debug|x86.Build.0 = Debug|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|Any CPU.Build.0 = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|x64.ActiveCfg = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|x64.Build.0 = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|x86.ActiveCfg = Release|Any CPU + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 6ca48683245351befec858c71d76b85e4b10919b Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 14 May 2026 14:27:10 -0700 Subject: [PATCH 04/16] Update Libraries.sln to put Durable Function project in right solution folder --- Libraries/Libraries.sln | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index 1bc34a173..65b4cd9e0 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -1101,6 +1101,10 @@ Global {80594C21-C6EB-469E-83CC-68F9F661CA5E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9} = {B5BD0336-7D08-492C-8489-42C987E29B39} {0768FA72-CF49-2B59-BC4C-E4CE579E5D93} = {B5BD0336-7D08-492C-8489-42C987E29B39} + {9097B5A4-E100-47FD-A676-0B666A36FAFF} = {AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12} + {57150BA6-3826-431F-8F58-B1D11FAFC5D4} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {CA132CAB-FF4F-4312-B3A3-66DE9D360F27} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} From e6a88cc6f76f756dec0a3ac759c5e3158ee194c7 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 14 May 2026 19:15:43 -0400 Subject: [PATCH 05/16] Use ILambdaContext.Serializer in DurableExecution; remove ICheckpointSerializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DurableExecution now reads the registered ILambdaSerializer from the per-invocation ILambdaContext (added in the prior PR) for both step-result checkpointing and workflow input/output. AOT-safety is now determined entirely by which serializer the user registers with LambdaBootstrapBuilder.Create — there is no longer a forked path between reflection-based and AOT-safe APIs. Removed: - ICheckpointSerializer + SerializationContext record - ReflectionJsonCheckpointSerializer - The four JsonSerializerContext-taking overloads of DurableFunction.WrapAsync - The IDurableContext.StepAsync overload that took ICheckpointSerializer - All [RequiresUnreferencedCode]/[RequiresDynamicCode] attributes and their related [UnconditionalSuppressMessage] shims Net result: 8 WrapAsync overloads → 4, 3 StepAsync overloads → 2, zero trim attributes in the public API. The AOT smoke test continues to publish with zero IL2026/IL3050 warnings. --- .../35ada24f-0a68-4947-aded-0a27de9ad05a.json | 11 ++ .../DurableContext.cs | 39 +--- .../DurableFunction.cs | 175 ++++-------------- .../ICheckpointSerializer.cs | 25 --- .../IDurableContext.cs | 23 +-- .../ReflectionJsonCheckpointSerializer.cs | 36 ---- .../Internal/StepOperation.cs | 26 ++- .../Program.cs | 20 +- ...mazon.Lambda.DurableExecution.Tests.csproj | 1 + .../ConfigTests.cs | 15 -- .../DurableContextTests.cs | 94 +++------- .../DurableFunctionTests.cs | 28 +-- 12 files changed, 125 insertions(+), 368 deletions(-) create mode 100644 .autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs diff --git a/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json b/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json new file mode 100644 index 000000000..47cffb695 --- /dev/null +++ b/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.DurableExecution", + "Type": "Patch", + "ChangelogMessages": [ + "Use ILambdaContext.Serializer for step checkpoint and workflow input/output serialization; remove ICheckpointSerializer, ReflectionJsonCheckpointSerializer, the JsonSerializerContext-taking WrapAsync overloads, and the [RequiresUnreferencedCode]/[RequiresDynamicCode] attributes that previously forked AOT vs reflection paths" + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs index 87a874c2d..e01a26604 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution.Internal; using Microsoft.Extensions.Logging; @@ -40,14 +39,12 @@ public DurableContext( public IExecutionContext ExecutionContext => new DurableExecutionContext(_durableExecutionArn); public ILambdaContext LambdaContext { get; } - [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] - [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] public Task StepAsync( Func> func, string? name = null, StepConfig? config = null, CancellationToken cancellationToken = default) - => RunStep(func, new ReflectionJsonCheckpointSerializer(), name, config, cancellationToken); + => RunStep(func, name, config, cancellationToken); public async Task StepAsync( Func func, @@ -55,30 +52,26 @@ public async Task StepAsync( StepConfig? config = null, CancellationToken cancellationToken = default) { - // Void steps don't carry a meaningful payload; we wrap with a null-only - // serializer that doesn't touch reflection. + // Void steps don't carry a meaningful payload — wrap with an object?-typed + // step that always returns null. The serializer isn't actually invoked + // with a non-null value, so any registered ILambdaSerializer suffices. await RunStep( async (ctx) => { await func(ctx); return null; }, - NullCheckpointSerializer.Instance, name, config, cancellationToken); } - public Task StepAsync( - Func> func, - ICheckpointSerializer serializer, - string? name = null, - StepConfig? config = null, - CancellationToken cancellationToken = default) - => RunStep(func, serializer, name, config, cancellationToken); - - private Task RunStep( Func> func, - ICheckpointSerializer serializer, string? name, StepConfig? config, CancellationToken cancellationToken) { + var serializer = LambdaContext.Serializer + ?? throw new InvalidOperationException( + "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + + "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + + "(or in tests, set TestLambdaContext.Serializer)."); + var operationId = _idGenerator.NextId(); var op = new StepOperation( operationId, name, func, config, serializer, Logger, @@ -110,18 +103,6 @@ public Task WaitAsync( } } -/// -/// Trim-safe serializer used by the void StepAsync overloads, which never -/// carry a meaningful payload. Always serializes to "null" and discards -/// on deserialize. -/// -internal sealed class NullCheckpointSerializer : ICheckpointSerializer -{ - public static NullCheckpointSerializer Instance { get; } = new(); - public string Serialize(object? value, SerializationContext context) => "null"; - public object? Deserialize(string data, SerializationContext context) => null; -} - internal sealed class DurableExecutionContext : IExecutionContext { public DurableExecutionContext(string durableExecutionArn) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index d629a0b2e..d170d6025 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -1,7 +1,5 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; +using System.IO; +using System.Text; using System.Threading; using Amazon.Lambda; using Amazon.Lambda.Core; @@ -15,60 +13,50 @@ namespace Amazon.Lambda.DurableExecution; /// /// Static helper that wraps a durable workflow function, handling all envelope /// translation between DurableExecutionInvocationInput/Output and user types. +/// +/// All four overloads dispatch through the registered +/// on , so AOT-safe and reflection-based +/// callers share a single code path. Callers wire AOT support by registering an +/// AOT-aware serializer with the runtime +/// (e.g., SourceGeneratorLambdaJsonSerializer<TContext>) — no per-call +/// JsonSerializerContext argument is required. /// public static class DurableFunction { private static readonly Lazy _cachedLambdaClient = new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); - // ────────────────────────────────────────────────────────────────────── - // Reflection-based overloads (JIT only) - // ────────────────────────────────────────────────────────────────────── - /// - /// Wrap a workflow (typed input + output). Reflection-based JSON — not AOT-safe. + /// Wrap a workflow (typed input + output). /// - [RequiresUnreferencedCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext) - { - return WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext: null); - } + => WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); /// /// Wrap a workflow (typed input + output) with explicit Lambda client. - /// Reflection-based JSON — not AOT-safe. /// - [RequiresUnreferencedCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON for TInput/TOutput. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, IAmazonLambda lambdaClient) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient, jsonContext: null); + => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient); /// - /// Wrap a void workflow (typed input, no output). Reflection-based JSON — not AOT-safe. + /// Wrap a void workflow (typed input, no output). /// - [RequiresUnreferencedCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext) - { - return WrapAsync(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); - } + => WrapAsync(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); /// - /// Wrap a void workflow with explicit Lambda client. Reflection-based JSON — not AOT-safe. + /// Wrap a void workflow with explicit Lambda client. /// - [RequiresUnreferencedCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON for TInput. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func workflow, DurableExecutionInvocationInput invocationInput, @@ -76,79 +64,20 @@ public static Task WrapAsync( IAmazonLambda lambdaClient) => WrapAsyncCore( async (input, ctx) => { await workflow(input, ctx); return null; }, - invocationInput, lambdaContext, lambdaClient, jsonContext: null); - - // ────────────────────────────────────────────────────────────────────── - // AOT-safe overloads (caller supplies JsonSerializerContext) - // ────────────────────────────────────────────────────────────────────── + invocationInput, lambdaContext, lambdaClient); - /// - /// Wrap a workflow (typed input + output). AOT-safe — requires - /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] - /// on the supplied . - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext) - { - return WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext); - } - - /// - /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient, jsonContext); - - /// - /// Wrap a void workflow (typed input, no output). AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext) - { - return WrapAsyncCore( - async (input, ctx) => { await workflow(input, ctx); return null; }, - invocationInput, lambdaContext, _cachedLambdaClient.Value, jsonContext); - } - - /// - /// Wrap a void workflow with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext) - => WrapAsyncCore( - async (input, ctx) => { await workflow(input, ctx); return null; }, - invocationInput, lambdaContext, lambdaClient, jsonContext); - - // ────────────────────────────────────────────────────────────────────── - // Core implementation - // ────────────────────────────────────────────────────────────────────── - - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "When jsonContext is non-null, dispatch goes through JsonTypeInfo; when null, the caller has [RequiresUnreferencedCode].")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "When jsonContext is non-null, dispatch goes through JsonTypeInfo; when null, the caller has [RequiresDynamicCode].")] private static async Task WrapAsyncCore( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext? jsonContext) + IAmazonLambda lambdaClient) { + var serializer = lambdaContext.Serializer + ?? throw new InvalidOperationException( + "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + + "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + + "(or in tests, set TestLambdaContext.Serializer)."); + var state = new ExecutionState(); state.LoadFromCheckpoint(invocationInput.InitialExecutionState); @@ -164,7 +93,7 @@ private static async Task WrapAsyncCore(invocationInput, jsonContext); + var userPayload = ExtractUserPayload(invocationInput, serializer); var terminationManager = new TerminationManager(); var idGenerator = new OperationIdGenerator(); @@ -195,7 +124,7 @@ private static async Task WrapAsyncCore @@ -241,26 +170,12 @@ private static bool IsTerminalCheckpointError(AmazonServiceException ex) return true; } - // Shared options for both user-payload deserialization (input) and user-result - // serialization (output) so the naming policy stays symmetric. We only enable - // case-insensitive matching here — keep PascalCase on the wire for output to - // preserve compatibility with existing serialized contracts. Only the user payload - // portion uses these options; the durable-execution envelope itself - // (DurableExecutionInvocationInput/Output) is serialized separately and is not - // affected. - private static readonly JsonSerializerOptions UserPayloadOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] // The user's input payload is stored inside the service envelope as an EXECUTION-type // operation. This is part of the durable execution wire format — each invocation includes // its input as a checkpoint record so the service can validate replay consistency. private static TInput ExtractUserPayload( DurableExecutionInvocationInput input, - JsonSerializerContext? jsonContext) + ILambdaSerializer serializer) { if (input.InitialExecutionState?.Operations == null) return default!; @@ -271,34 +186,24 @@ private static TInput ExtractUserPayload( continue; var payload = op.ExecutionDetails.InputPayload; - if (jsonContext != null) - { - if (jsonContext.GetTypeInfo(typeof(TInput)) is JsonTypeInfo typeInfo) - return JsonSerializer.Deserialize(payload, typeInfo) ?? default!; - - throw new InvalidOperationException( - $"JsonSerializerContext {jsonContext.GetType().FullName} has no JsonTypeInfo for {typeof(TInput).FullName}. " + - "Add [JsonSerializable(typeof(YourInput))] to your context."); - } - - return JsonSerializer.Deserialize(payload, UserPayloadOptions) ?? default!; + var bytes = Encoding.UTF8.GetBytes(payload); + using var ms = new MemoryStream(bytes); + return serializer.Deserialize(ms); } return default!; } - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] private static DurableExecutionInvocationOutput MapToOutput( HandlerResult result, - JsonSerializerContext? jsonContext) + ILambdaSerializer serializer) { return result.Status switch { InvocationStatus.Succeeded => new DurableExecutionInvocationOutput { Status = InvocationStatus.Succeeded, - Result = SerializeOutput(result.Result, jsonContext) + Result = SerializeOutput(result.Result, serializer) }, InvocationStatus.Failed => new DurableExecutionInvocationOutput { @@ -317,22 +222,12 @@ private static DurableExecutionInvocationOutput MapToOutput( }; } - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Guarded by jsonContext null check.")] - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Guarded by jsonContext null check.")] - private static string? SerializeOutput(TOutput? value, JsonSerializerContext? jsonContext) + private static string? SerializeOutput(TOutput? value, ILambdaSerializer serializer) { if (value == null) return null; - if (jsonContext != null) - { - if (jsonContext.GetTypeInfo(typeof(TOutput)) is JsonTypeInfo typeInfo) - return JsonSerializer.Serialize(value, typeInfo); - - throw new InvalidOperationException( - $"JsonSerializerContext {jsonContext.GetType().FullName} has no JsonTypeInfo for {typeof(TOutput).FullName}. " + - "Add [JsonSerializable(typeof(YourOutput))] to your context."); - } - - return JsonSerializer.Serialize(value, UserPayloadOptions); + using var ms = new MemoryStream(); + serializer.Serialize(value, ms); + return Encoding.UTF8.GetString(ms.ToArray()); } } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs deleted file mode 100644 index 3d7175b4d..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/ICheckpointSerializer.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Amazon.Lambda.DurableExecution; - -/// -/// Serializes and deserializes checkpoint operation results. -/// -/// The type to serialize. -public interface ICheckpointSerializer -{ - /// - /// Serializes a value for checkpoint storage. - /// - string Serialize(T value, SerializationContext context); - - /// - /// Deserializes a value from checkpoint storage. - /// - T Deserialize(string data, SerializationContext context); -} - -/// -/// Context information available during serialization/deserialization. -/// -/// The deterministic operation ID for this step. -/// The ARN of the current durable execution. -public record SerializationContext(string OperationId, string DurableExecutionArn); diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs index ff18d1218..581b02a94 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Amazon.Lambda.Core; using Microsoft.Extensions.Logging; @@ -32,12 +31,13 @@ public interface IDurableContext /// /// Execute a step with automatic checkpointing. The step result is serialized - /// to a checkpoint using reflection-based System.Text.Json. - /// For NativeAOT or trimmed deployments, use the overload that takes an - /// . + /// to a checkpoint using the registered on + /// (typically configured via + /// LambdaBootstrapBuilder.Create(handler, serializer)). AOT and + /// reflection-based scenarios share this single overload — the AOT story is + /// determined by the registered serializer (e.g., + /// SourceGeneratorLambdaJsonSerializer<TContext>). /// - [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] - [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] Task StepAsync( Func> func, string? name = null, @@ -53,17 +53,6 @@ Task StepAsync( StepConfig? config = null, CancellationToken cancellationToken = default); - /// - /// Execute a step with AOT-safe checkpoint serialization. The supplied - /// is used in place of reflection-based JSON. - /// - Task StepAsync( - Func> func, - ICheckpointSerializer serializer, - string? name = null, - StepConfig? config = null, - CancellationToken cancellationToken = default); - /// /// Suspend execution for the specified duration without consuming compute time. /// The Lambda is suspended and the service re-invokes it after the wait elapses. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs deleted file mode 100644 index f7a3d0572..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ReflectionJsonCheckpointSerializer.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; - -namespace Amazon.Lambda.DurableExecution.Internal; - -/// -/// Default backed by reflection-based -/// . Constructed only by the reflection-overload -/// path of DurableContext.StepAsync; the constructor carries -/// so AOT/trimmed deployments -/// see the warning at the call site that picks this overload. -/// -internal sealed class ReflectionJsonCheckpointSerializer : ICheckpointSerializer -{ - [RequiresUnreferencedCode("Uses reflection-based JsonSerializer; not AOT-safe.")] - [RequiresDynamicCode("Uses reflection-based JsonSerializer; not AOT-safe.")] - public ReflectionJsonCheckpointSerializer() { } - - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] - public string Serialize(T value, SerializationContext context) - { - return JsonSerializer.Serialize(value); - } - - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "Reflection-based JsonSerializer call is acknowledged on the constructor.")] - public T Deserialize(string data, SerializationContext context) - { - return JsonSerializer.Deserialize(data)!; - } -} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs index 2decdb309..4b981676e 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs @@ -1,3 +1,6 @@ +using System.IO; +using System.Text; +using Amazon.Lambda.Core; using Microsoft.Extensions.Logging; using SdkErrorObject = Amazon.Lambda.Model.ErrorObject; using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; @@ -16,16 +19,17 @@ namespace Amazon.Lambda.DurableExecution.Internal; /// Replay (SUCCEEDED): return cached result; func is NOT re-executed. /// Replay (FAILED): re-throw the recorded exception. /// -/// Serialization is delegated to the supplied ; -/// the AOT-safe overloads of IDurableContext.StepAsync wire in a -/// user-supplied serializer, while the reflection overloads inject -/// . +/// Serialization is delegated to the registered on +/// . AOT-safe and reflection-based callers +/// share the same code path: the AOT story is determined entirely by the serializer +/// the user registered with the runtime (e.g., +/// SourceGeneratorLambdaJsonSerializer<TContext>). /// internal sealed class StepOperation : DurableOperation { private readonly Func> _func; private readonly StepConfig? _config; - private readonly ICheckpointSerializer _serializer; + private readonly ILambdaSerializer _serializer; private readonly ILogger _logger; public StepOperation( @@ -33,7 +37,7 @@ public StepOperation( string? name, Func> func, StepConfig? config, - ICheckpointSerializer serializer, + ILambdaSerializer serializer, ILogger logger, ExecutionState state, TerminationManager termination, @@ -134,11 +138,17 @@ await EnqueueAsync(new SdkOperationUpdate private T DeserializeResult(string? serialized) { if (serialized == null) return default!; - return _serializer.Deserialize(serialized, new SerializationContext(OperationId, DurableExecutionArn)); + var bytes = Encoding.UTF8.GetBytes(serialized); + using var ms = new MemoryStream(bytes); + return _serializer.Deserialize(ms); } private string SerializeResult(T value) - => _serializer.Serialize(value, new SerializationContext(OperationId, DurableExecutionArn)); + { + using var ms = new MemoryStream(); + _serializer.Serialize(value, ms); + return Encoding.UTF8.GetString(ms.ToArray()); + } private static StepException CreateStepException(Operation failedOp) { diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs index af84aca8c..2b846bff1 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs @@ -8,8 +8,10 @@ namespace Amazon.Lambda.DurableExecution.AotPublishTest; /// /// AOT publish smoke check. This program must publish under NativeAOT with -/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). It uses -/// the JsonSerializerContext overload of WrapAsync. +/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). The serializer +/// registered with is the same one DurableExecution +/// reads via , so AOT-safety is fully determined +/// by the user's choice of serializer (here, ). /// public class Program { @@ -25,8 +27,7 @@ await LambdaBootstrapBuilder public static Task HandlerAsync( DurableExecutionInvocationInput input, ILambdaContext context) => - DurableFunction.WrapAsync( - WorkflowAsync, input, context, AotJsonContext.Default); + DurableFunction.WrapAsync(WorkflowAsync, input, context); private static async Task WorkflowAsync(OrderEvent input, IDurableContext context) { @@ -36,7 +37,6 @@ private static async Task WorkflowAsync(OrderEvent input, IDurableC await Task.CompletedTask; return new ValidationResult { IsValid = true }; }, - new ValidationResultSerializer(), name: "validate"); await context.WaitAsync(TimeSpan.FromSeconds(30), name: "delay"); @@ -44,16 +44,6 @@ private static async Task WorkflowAsync(OrderEvent input, IDurableC return new OrderResult { Status = validation.IsValid ? "approved" : "rejected", OrderId = input.OrderId }; } - private sealed class ValidationResultSerializer : ICheckpointSerializer - { - public string Serialize(ValidationResult value, SerializationContext ctx) => - System.Text.Json.JsonSerializer.Serialize(value, AotJsonContext.Default.ValidationResult); - - public ValidationResult Deserialize(string data, SerializationContext ctx) => - System.Text.Json.JsonSerializer.Deserialize(data, AotJsonContext.Default.ValidationResult) - ?? new ValidationResult(); - } - public class OrderEvent { public string? OrderId { get; set; } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj index 6fa422e0a..6f9abfe62 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs deleted file mode 100644 index f31586ea0..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ConfigTests.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Amazon.Lambda.DurableExecution; -using Xunit; - -namespace Amazon.Lambda.DurableExecution.Tests; - -public class ConfigTests -{ - [Fact] - public void SerializationContext_RecordEquality() - { - var ctx1 = new SerializationContext("op-1", "arn:aws:lambda:us-east-1:123:function:my-func"); - var ctx2 = new SerializationContext("op-1", "arn:aws:lambda:us-east-1:123:function:my-func"); - Assert.Equal(ctx1, ctx2); - } -} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs index 806ebd844..6bb5b517f 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs @@ -1,6 +1,7 @@ using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.Serialization.SystemTextJson; using Amazon.Lambda.TestUtilities; using Xunit; @@ -11,6 +12,9 @@ public class DurableContextTests /// Reproduces the Id that emits for the n-th root-level operation. private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); + private static TestLambdaContext CreateLambdaContext() => + new() { Serializer = new DefaultLambdaJsonSerializer() }; + private static DurableContext CreateContext( InitialExecutionState? initialState = null, TerminationManager? terminationManager = null) @@ -19,7 +23,7 @@ private static DurableContext CreateContext( state.LoadFromCheckpoint(initialState); var tm = terminationManager ?? new TerminationManager(); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); return new DurableContext(state, tm, idGen, "arn:aws:lambda:us-east-1:123:durable-execution:test", lambdaContext); } @@ -198,19 +202,21 @@ public async Task StepAsync_ComplexType_SerializesCorrectly() } [Fact] - public async Task StepAsync_CustomSerializer_UsedForSerialization() + public async Task StepAsync_NoSerializerOnContext_ThrowsInvalidOperation() { - var serializer = new RecordingSerializer(); - var context = CreateContext(); + // The serializer comes from ILambdaContext.Serializer — without one, + // we can't checkpoint anything. The error message points users at the + // bootstrap registration point. + var state = new ExecutionState(); + var tm = new TerminationManager(); + var idGen = new OperationIdGenerator(); + var lambdaContext = new TestLambdaContext(); // no Serializer set + var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); - var result = await context.StepAsync( - async (_) => { await Task.CompletedTask; return new TestPerson { Name = "Charlie", Age = 40 }; }, - serializer, - name: "with_custom"); + var ex = await Assert.ThrowsAsync(() => + context.StepAsync(async (_) => { await Task.CompletedTask; return "x"; }, name: "no_serializer")); - Assert.Equal("Charlie", result.Name); - Assert.True(serializer.SerializeCalled); - Assert.False(serializer.DeserializeCalled); + Assert.Contains("ILambdaSerializer", ex.Message); } [Fact] @@ -277,34 +283,6 @@ await Assert.ThrowsAnyAsync(() => cancellationToken: cts.Token)); } - [Fact] - public async Task StepAsync_CustomSerializer_UsedForReplayDeserialization() - { - var serializer = new RecordingSerializer(); - var context = CreateContext(new InitialExecutionState - { - Operations = new List - { - new() - { - Id = IdAt(1), - Type = OperationTypes.Step, - Status = OperationStatuses.Succeeded, - StepDetails = new StepDetails { Result = "Dana,55" } - } - } - }); - - var result = await context.StepAsync( - async (_) => { await Task.CompletedTask; return new TestPerson { Name = "ignored", Age = 0 }; }, - serializer, - name: "replay_step"); - - Assert.True(serializer.DeserializeCalled); - Assert.Equal("Dana", result.Name); - Assert.Equal(55, result.Age); - } - #endregion #region WaitAsync Tests @@ -385,7 +363,7 @@ public async Task WaitAsync_StartedButNotExpired_ResuspendsWithoutNewCheckpoint( } }); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var recorder = new RecordingBatcher(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext, recorder.Batcher); @@ -452,7 +430,7 @@ public async Task EndToEnd_StepWaitStep_FirstInvocation_SuspendsOnWait() var state = new ExecutionState(); state.LoadFromCheckpoint(null); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); var result = await DurableExecutionHandler.RunAsync( @@ -496,7 +474,7 @@ public async Task EndToEnd_StepWaitStep_SecondInvocation_Completes() }); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); var processExecuted = false; @@ -546,7 +524,7 @@ public async Task StepAsync_ReplayTypeMismatch_ThrowsNonDeterministicException() }); var tm = new TerminationManager(); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); var ex = await Assert.ThrowsAsync(async () => @@ -577,7 +555,7 @@ public async Task WaitAsync_ReplayTypeMismatch_ThrowsNonDeterministicException() }); var tm = new TerminationManager(); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); var ex = await Assert.ThrowsAsync(async () => @@ -609,7 +587,7 @@ public async Task StepAsync_ReplayNameMismatch_ThrowsNonDeterministicException() }); var tm = new TerminationManager(); var idGen = new OperationIdGenerator(); - var lambdaContext = new TestLambdaContext(); + var lambdaContext = CreateLambdaContext(); var context = new DurableContext(state, tm, idGen, "arn:test", lambdaContext); var ex = await Assert.ThrowsAsync(async () => @@ -640,30 +618,4 @@ private class TestPerson public string? Name { get; set; } public int Age { get; set; } } - - /// - /// AOT-friendly test serializer using a trivial format. Demonstrates that - /// passing an to the AOT-safe - /// StepAsync overload fully replaces the reflection-based - /// System.Text.Json path. - /// - private class RecordingSerializer : ICheckpointSerializer - { - public bool SerializeCalled { get; private set; } - public bool DeserializeCalled { get; private set; } - - public string Serialize(TestPerson value, SerializationContext context) - { - SerializeCalled = true; - return $"{value.Name},{value.Age}"; - } - - public TestPerson Deserialize(string data, SerializationContext context) - { - DeserializeCalled = true; - var inner = data.Replace("", "").Replace("", ""); - var parts = inner.Split(','); - return new TestPerson { Name = parts[0], Age = int.Parse(parts[1]) }; - } - } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index 032a25a66..a2f27e4e5 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -3,6 +3,7 @@ using Amazon.Lambda; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.Serialization.SystemTextJson; using Amazon.Lambda.TestUtilities; using Amazon.Runtime; using Xunit; @@ -18,6 +19,9 @@ public class DurableFunctionTests /// Reproduces the Id that emits for the n-th root-level operation. private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); + private static TestLambdaContext CreateLambdaContext() => + new() { Serializer = new DefaultLambdaJsonSerializer() }; + private readonly IAmazonLambda _mockClient = new MockLambdaClient(); [Fact] @@ -44,7 +48,7 @@ public async Task WrapAsync_FreshExecution_StepThenWait_ReturnsPending() var output = await DurableFunction.WrapAsync( MyWorkflow, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); @@ -89,7 +93,7 @@ public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() var output = await DurableFunction.WrapAsync( MyWorkflow, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -122,7 +126,7 @@ public async Task WrapAsync_WorkflowThrows_ReturnsFailed() var output = await DurableFunction.WrapAsync( async (evt, ctx) => throw new InvalidOperationException("workflow error"), input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); @@ -159,7 +163,7 @@ public async Task WrapAsync_VoidWorkflow_ReturnSucceeded() await ctx.StepAsync(async (_) => { await Task.CompletedTask; executed = true; }, name: "do_work"); }, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -192,7 +196,7 @@ public async Task WrapAsync_CheckpointsAreSentToService() var output = await DurableFunction.WrapAsync( MyWorkflow, input, - new TestLambdaContext(), + CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); @@ -254,7 +258,7 @@ public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -284,7 +288,7 @@ public async Task WrapAsync_NoExecutionOp_ReceivesDefaultPayload() return new OrderResult { Status = "ok" }; }, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -383,7 +387,7 @@ public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, input, - new TestLambdaContext(), + CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -420,7 +424,7 @@ public async Task WrapAsync_NullInitialExecutionState_ReceivesDefaultPayload() return new OrderResult { Status = "ok" }; }, input, - new TestLambdaContext(), + CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -455,7 +459,7 @@ public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonService var mockClient = new MockLambdaClient { CheckpointThrows = ex }; var output = await DurableFunction.WrapAsync( - SingleStepWorkflow, input, new TestLambdaContext(), mockClient); + SingleStepWorkflow, input, CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); Assert.NotNull(output.Error); @@ -484,7 +488,7 @@ public async Task WrapAsync_CheckpointThrowsTransient_PropagatesToHost(AmazonSer var thrown = await Assert.ThrowsAsync(ex.GetType(), () => DurableFunction.WrapAsync( - SingleStepWorkflow, input, new TestLambdaContext(), mockClient)); + SingleStepWorkflow, input, CreateLambdaContext(), mockClient)); Assert.Same(ex, thrown); } @@ -519,7 +523,7 @@ public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() var thrown = await Assert.ThrowsAsync(() => DurableFunction.WrapAsync( - MyWorkflow, input, new TestLambdaContext(), mockClient)); + MyWorkflow, input, CreateLambdaContext(), mockClient)); Assert.Same(ex, thrown); } From c3c251b0a7316b5aa47fb23685824f213833263b Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 14 May 2026 20:35:59 -0400 Subject: [PATCH 06/16] Address PR review feedback - Wrap LambdaDurableServiceClient SDK calls in DurableExecutionException with durable-execution context (which call, which ARN). User logs no longer show bare AWSSDK stack traces. Update IsTerminalCheckpointError to unwrap the inner AmazonServiceException for classification. - Move public-API files out of Models/, Config/, Exceptions/ into the project root so folder layout matches the Amazon.Lambda.DurableExecution namespace. - Replace string action literals ("SUCCEED", "FAIL", "START") with the Amazon.Lambda.OperationAction enum constants. - Replace hand-rolled ToHex with Amazon.Util.AWSSDKUtils.ToHex. Drop the netstandard2.0 SHA-256 fallback now that DurableExecution targets net8+. - Spell "iff" as "if and only if" in ExecutionState replay-mode docs. Tests updated for the new wrapping shape: terminal classification asserts on DurableExecutionException with the inner SDK exception preserved; transient and hydration paths assert ThrowsAsync with InnerException set to the original AmazonServiceException. --- .../Amazon.Lambda.DurableExecution.csproj | 4 +++ .../DurableExecutionException.cs | 0 .../DurableExecutionInvocationInput.cs | 0 .../DurableExecutionInvocationOutput.cs | 0 .../DurableFunction.cs | 16 +++++++--- .../{Models => }/ErrorObject.cs | 0 .../Internal/ExecutionState.cs | 2 +- .../Internal/OperationIdGenerator.cs | 24 ++------------ .../Internal/StepOperation.cs | 5 +-- .../Internal/WaitOperation.cs | 3 +- .../Services/LambdaDurableServiceClient.cs | 32 +++++++++++++++++-- .../{Config => }/StepConfig.cs | 0 .../DurableFunctionTests.cs | 23 ++++++++++--- 13 files changed, 71 insertions(+), 38 deletions(-) rename Libraries/src/Amazon.Lambda.DurableExecution/{Exceptions => }/DurableExecutionException.cs (100%) rename Libraries/src/Amazon.Lambda.DurableExecution/{Models => }/DurableExecutionInvocationInput.cs (100%) rename Libraries/src/Amazon.Lambda.DurableExecution/{Models => }/DurableExecutionInvocationOutput.cs (100%) rename Libraries/src/Amazon.Lambda.DurableExecution/{Models => }/ErrorObject.cs (100%) rename Libraries/src/Amazon.Lambda.DurableExecution/{Config => }/StepConfig.cs (100%) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj index de02d8ce2..9c0dc747b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj @@ -16,6 +16,10 @@ enable true IL2026,IL2067,IL2075,IL3050 + + $(NoWarn);AWSLAMBDA001 diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionException.cs similarity index 100% rename from Libraries/src/Amazon.Lambda.DurableExecution/Exceptions/DurableExecutionException.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionException.cs diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs similarity index 100% rename from Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationInput.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs similarity index 100% rename from Libraries/src/Amazon.Lambda.DurableExecution/Models/DurableExecutionInvocationOutput.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index d170d6025..178a10604 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -115,7 +115,7 @@ private static async Task WrapAsyncCore WrapAsyncCore /// Returns true for checkpoint-flush SDK errors that should fail the workflow - /// (Failed envelope) instead of escaping to the host (Lambda retry). + /// (Failed envelope) instead of escaping to the host (Lambda retry). The catch + /// site unwraps a first because + /// wraps every SDK error so + /// user logs show durable-execution context — this method then classifies the + /// inner . /// /// /// Classification rule (mirrors CheckpointError in aws-durable-execution-sdk-python): @@ -143,11 +147,13 @@ private static async Task WrapAsyncCoreStepAsync call - /// (the user awaits EnqueueAsync → batch flush → SDK throws). + /// (the user awaits EnqueueAsync → batch flush → SDK throws → service client + /// wraps). /// 2. The final after the workflow returns. /// - /// State-hydration errors (GetExecutionStateAsync) are NOT caught here — they - /// propagate to the host so Lambda retries, matching Python's GetExecutionStateError + /// State-hydration errors (GetExecutionStateAsync) propagate as + /// too, but they are NOT caught here — they + /// flow up to the host so Lambda retries, matching Python's GetExecutionStateError /// (which extends InvocationError). /// /// User-code SDK errors (e.g. an SDK call inside a Step body) are caught by diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs similarity index 100% rename from Libraries/src/Amazon.Lambda.DurableExecution/Models/ErrorObject.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs index 606614621..08d734d05 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs @@ -8,7 +8,7 @@ namespace Amazon.Lambda.DurableExecution.Internal; /// /// Replay tracking mirrors the Python / Java / JavaScript reference SDKs: /// -/// At construction the workflow is "replaying" iff any user-replayable +/// At construction the workflow is "replaying" if and only if any user-replayable /// op is present. The service always sends one EXECUTION-type op /// carrying the input payload — that's bookkeeping, not user history, /// so it doesn't count. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs index fef9cab19..9a42a889c 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using Amazon.Util; namespace Amazon.Lambda.DurableExecution.Internal; @@ -60,27 +61,8 @@ public string NextId() public static string HashOperationId(string rawId) { var bytes = Encoding.UTF8.GetBytes(rawId); - Span hash = stackalloc byte[32]; -#if NET8_0_OR_GREATER - SHA256.HashData(bytes, hash); -#else - using var sha = SHA256.Create(); - var computed = sha.ComputeHash(bytes); - computed.CopyTo(hash); -#endif - return ToHex(hash); - } - - private static string ToHex(ReadOnlySpan bytes) - { - const string Hex = "0123456789abcdef"; - var chars = new char[bytes.Length * 2]; - for (int i = 0; i < bytes.Length; i++) - { - chars[i * 2] = Hex[bytes[i] >> 4]; - chars[i * 2 + 1] = Hex[bytes[i] & 0xF]; - } - return new string(chars); + var hash = SHA256.HashData(bytes); + return AWSSDKUtils.ToHex(hash, lowercase: true); } /// diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs index 4b981676e..42c9e3461 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/StepOperation.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text; +using Amazon.Lambda; using Amazon.Lambda.Core; using Microsoft.Extensions.Logging; using SdkErrorObject = Amazon.Lambda.Model.ErrorObject; @@ -101,7 +102,7 @@ await EnqueueAsync(new SdkOperationUpdate { Id = OperationId, Type = OperationTypes.Step, - Action = "SUCCEED", + Action = OperationAction.SUCCEED, SubType = "Step", Name = Name, Payload = SerializeResult(result) @@ -122,7 +123,7 @@ await EnqueueAsync(new SdkOperationUpdate { Id = OperationId, Type = OperationTypes.Step, - Action = "FAIL", + Action = OperationAction.FAIL, SubType = "Step", Name = Name, Error = ToSdkError(ex) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs index 59254827d..e8351c120 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/WaitOperation.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda; using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; using SdkWaitOptions = Amazon.Lambda.Model.WaitOptions; @@ -47,7 +48,7 @@ await EnqueueAsync(new SdkOperationUpdate { Id = OperationId, Type = OperationTypes.Wait, - Action = "START", + Action = OperationAction.START, SubType = "Wait", Name = Name, WaitOptions = new SdkWaitOptions { WaitSeconds = _waitSeconds } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs index 709341760..761391a7b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs @@ -1,5 +1,6 @@ using Amazon.Lambda.DurableExecution.Internal; using Amazon.Lambda.Model; +using Amazon.Runtime; using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; using SdkOperation = Amazon.Lambda.Model.Operation; @@ -19,6 +20,10 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) /// /// Flushes pending checkpoint operations to the durable execution service. + /// SDK errors are wrapped in so user logs + /// show the durable-execution context (which API call, which ARN) alongside the + /// underlying SDK message — instead of a bare AWSSDK stack trace with no clue + /// about what was being called. /// public async Task CheckpointAsync( string durableExecutionArn, @@ -36,12 +41,23 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) Updates = pendingOperations is List list ? list : pendingOperations.ToList() }; - var response = await _lambdaClient.CheckpointDurableExecutionAsync(request, cancellationToken); - return response.CheckpointToken; + try + { + var response = await _lambdaClient.CheckpointDurableExecutionAsync(request, cancellationToken); + return response.CheckpointToken; + } + catch (AmazonServiceException ex) + { + throw new DurableExecutionException( + $"Failed to checkpoint operations for durable execution '{durableExecutionArn}': {ex.Message}", + ex); + } } /// /// Fetches additional pages of execution state when the initial state is paginated. + /// SDK errors are wrapped in for the same + /// reason as . /// public async Task<(List Operations, string? NextMarker)> GetExecutionStateAsync( string durableExecutionArn, @@ -56,7 +72,17 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) Marker = marker }; - var response = await _lambdaClient.GetDurableExecutionStateAsync(request, cancellationToken); + GetDurableExecutionStateResponse response; + try + { + response = await _lambdaClient.GetDurableExecutionStateAsync(request, cancellationToken); + } + catch (AmazonServiceException ex) + { + throw new DurableExecutionException( + $"Failed to fetch execution state for durable execution '{durableExecutionArn}' (marker '{marker}'): {ex.Message}", + ex); + } var operations = new List(); if (response.Operations != null) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs b/Libraries/src/Amazon.Lambda.DurableExecution/StepConfig.cs similarity index 100% rename from Libraries/src/Amazon.Lambda.DurableExecution/Config/StepConfig.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/StepConfig.cs diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index a2f27e4e5..999789fd4 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -455,6 +455,9 @@ public static IEnumerable TerminalCheckpointErrorCases() => new[] [MemberData(nameof(TerminalCheckpointErrorCases))] public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) { + // LambdaDurableServiceClient now wraps SDK exceptions in DurableExecutionException + // so user logs carry context (which call, which ARN). The outer message includes + // the inner SDK message; the classifier matches on the wrapper's InnerException. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; @@ -463,7 +466,8 @@ public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonService Assert.Equal(InvocationStatus.Failed, output.Status); Assert.NotNull(output.Error); - Assert.Equal(ex.Message, output.Error!.ErrorMessage); + Assert.Contains(ex.Message, output.Error!.ErrorMessage); + Assert.Contains("Failed to checkpoint", output.Error.ErrorMessage); } public static IEnumerable TransientCheckpointErrorCases() => new[] @@ -483,14 +487,19 @@ public static IEnumerable TransientCheckpointErrorCases() => new[] [MemberData(nameof(TransientCheckpointErrorCases))] public async Task WrapAsync_CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) { + // Transient SDK errors escape the IsTerminalCheckpointError catch and propagate + // to the host as DurableExecutionException wrapping the original SDK exception + // — Lambda's normal retry semantics fire on the wrapper. The original SDK + // exception is preserved as InnerException so callers can still introspect + // the original status code / error code. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; - var thrown = await Assert.ThrowsAsync(ex.GetType(), () => + var thrown = await Assert.ThrowsAsync(() => DurableFunction.WrapAsync( SingleStepWorkflow, input, CreateLambdaContext(), mockClient)); - Assert.Same(ex, thrown); + Assert.Same(ex, thrown.InnerException); } [Fact] @@ -521,11 +530,15 @@ public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() var ex = MakeServiceException("ResourceNotFoundException", HttpStatusCode.NotFound, "ARN gone"); var mockClient = new MockLambdaClient { GetExecutionStateThrows = ex }; - var thrown = await Assert.ThrowsAsync(() => + // Hydration errors are wrapped in DurableExecutionException by + // LambdaDurableServiceClient.GetExecutionStateAsync but are NOT caught by the + // IsTerminalCheckpointError filter, so they escape to the host. + var thrown = await Assert.ThrowsAsync(() => DurableFunction.WrapAsync( MyWorkflow, input, CreateLambdaContext(), mockClient)); - Assert.Same(ex, thrown); + Assert.Same(ex, thrown.InnerException); + Assert.Contains("Failed to fetch execution state", thrown.Message); } private static AmazonServiceException MakeServiceException(string code, HttpStatusCode status, string message) From a3aed6e1f40bb8e1614ccb6c2838e12d79e5e566 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Sun, 17 May 2026 14:27:21 -0400 Subject: [PATCH 07/16] Delete .autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json --- .../changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json diff --git a/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json b/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json deleted file mode 100644 index 47cffb695..000000000 --- a/.autover/changes/35ada24f-0a68-4947-aded-0a27de9ad05a.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.DurableExecution", - "Type": "Patch", - "ChangelogMessages": [ - "Use ILambdaContext.Serializer for step checkpoint and workflow input/output serialization; remove ICheckpointSerializer, ReflectionJsonCheckpointSerializer, the JsonSerializerContext-taking WrapAsync overloads, and the [RequiresUnreferencedCode]/[RequiresDynamicCode] attributes that previously forked AOT vs reflection paths" - ] - } - ] -} From d997dc2deb1e969251bdbe5ac38167d6c84be1b2 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Sun, 17 May 2026 16:07:56 -0400 Subject: [PATCH 08/16] copilot comments --- .gitignore | 3 ++ .../Internal/OperationIdGenerator.cs | 17 +++++-- .../DurableContextTests.cs | 2 + .../DurableFunctionTests.cs | 2 + .../OperationIdGeneratorTests.cs | 45 ++++++++++++++----- .../UpperSnakeCaseEnumConverterTests.cs | 2 +- 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 1caae6fe4..f86678d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ global.json **/cdk.out/** **/.DS_Store + +# JetBrains Rider per-project cache +**/*.lscache diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs index 9a42a889c..4e9527d3c 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/OperationIdGenerator.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Threading; using Amazon.Util; namespace Amazon.Lambda.DurableExecution.Internal; @@ -47,9 +48,18 @@ public OperationIdGenerator(string? parentId) /// Generates the next operation ID. The counter is pre-incremented so the /// first ID is hash("1"), matching the reference SDKs. /// + /// + /// Uses so concurrent callers + /// (e.g. user code that wraps multiple StepAsync calls in + /// Task.WhenAll with Task.Run, or future ParallelAsync/ + /// MapAsync branches that fan out before awaiting) cannot collide + /// on the same ID. Determinism still requires that calls happen in a + /// deterministic order — atomicity prevents duplicate IDs but not + /// reordering between replays. Matches Java's AtomicInteger.incrementAndGet. + /// public string NextId() { - var counter = ++_counter; + var counter = Interlocked.Increment(ref _counter); return HashOperationId(_prefix + counter.ToString(System.Globalization.CultureInfo.InvariantCulture)); } @@ -74,10 +84,11 @@ public OperationIdGenerator CreateChild(string operationId) } /// - /// Resets the counter (used for testing only). + /// Resets the counter (used for testing only). Not safe to call concurrently + /// with ; tests must quiesce before resetting. /// internal void Reset() { - _counter = 0; + Interlocked.Exchange(ref _counter, 0); } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs index 6bb5b517f..7f362d605 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableContextTests.cs @@ -13,7 +13,9 @@ public class DurableContextTests private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); private static TestLambdaContext CreateLambdaContext() => +#pragma warning disable AWSLAMBDA001 // TestLambdaContext.Serializer is experimental. new() { Serializer = new DefaultLambdaJsonSerializer() }; +#pragma warning restore AWSLAMBDA001 private static DurableContext CreateContext( InitialExecutionState? initialState = null, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index 999789fd4..1bbc23b47 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -20,7 +20,9 @@ public class DurableFunctionTests private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); private static TestLambdaContext CreateLambdaContext() => +#pragma warning disable AWSLAMBDA001 // TestLambdaContext.Serializer is experimental. new() { Serializer = new DefaultLambdaJsonSerializer() }; +#pragma warning restore AWSLAMBDA001 private readonly IAmazonLambda _mockClient = new MockLambdaClient(); diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs index 6eb63551b..db8fd2f10 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/OperationIdGeneratorTests.cs @@ -25,17 +25,6 @@ public void NextId_ProducesSha256OfPositionString_StartingAtOne() Assert.Equal(Sha256Hex("3"), gen.NextId()); } - [Fact] - public void NextId_NameIsNotPartOfId() - { - // Name must not influence the deterministic ID — replays must still - // correlate after a step is renamed. The reference SDKs (Java/JS/Python) - // all keep Name in a separate field on OperationUpdate. - var gen = new OperationIdGenerator(); - Assert.Equal(Sha256Hex("1"), gen.NextId()); - Assert.Equal(Sha256Hex("2"), gen.NextId()); - } - [Fact] public void HashOperationId_IsStable() { @@ -97,4 +86,38 @@ public void Reset_RewindsCounter() gen.Reset(); Assert.Equal(Sha256Hex("1"), gen.NextId()); } + + [Fact] + public async Task NextId_ConcurrentCallers_ProduceUniqueIds() + { + // Without Interlocked.Increment, two threads racing on ++_counter can + // both observe the same pre-increment value and emit duplicate IDs, + // silently breaking replay determinism. Drive enough contention to + // catch a regression: many parallel callers, each making many calls. + const int threads = 16; + const int idsPerThread = 500; + const int total = threads * idsPerThread; + + var gen = new OperationIdGenerator(); + var allIds = new string[total]; + var start = new ManualResetEventSlim(false); + + var tasks = Enumerable.Range(0, threads).Select(t => Task.Run(() => + { + start.Wait(); + for (var i = 0; i < idsPerThread; i++) + { + allIds[t * idsPerThread + i] = gen.NextId(); + } + })).ToArray(); + + start.Set(); + await Task.WhenAll(tasks); + + Assert.Equal(total, allIds.Distinct().Count()); + + // Counter advanced exactly `total` times — the next ID must be hash("total+1"). + Assert.Equal(Sha256Hex((total + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)), + gen.NextId()); + } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs index 7ac6df052..679a49b6f 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs @@ -43,7 +43,7 @@ public void Read_NullValue_ReturnsDefault() } [Fact] - public void Read_AlreadyPascalCase_ParsesCaseInsensitively() + public void Read_CamelCase_ParsesCaseInsensitively() { // The converter first tries snake→pascal, then a raw case-insensitive parse. // A camel-case input like "fooBar" hits the fallback path. From 0f03bbed07fbe742e2983b5d5c807c93c8c06bf7 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 18 May 2026 13:30:36 -0400 Subject: [PATCH 09/16] Address PR review feedback (perf, error surface, visibility) - ExecutionState.TrackReplay is now O(1) amortized via a remaining-ops counter populated at LoadFromCheckpoint time. - ExtractUserPayload throws DurableExecutionException on a malformed envelope (missing EXECUTION op) instead of silently returning default!. - UpperSnakeCaseEnumConverter is now internal and lives in the ...DurableExecution.Internal namespace. --- .../DurableExecutionInvocationOutput.cs | 1 + .../DurableFunction.cs | 29 +++++---- .../Internal/ExecutionState.cs | 31 +++++----- .../Internal/UpperSnakeCaseEnumConverter.cs | 5 +- .../DurableFunctionTests.cs | 60 +++++++++---------- .../UpperSnakeCaseEnumConverterTests.cs | 1 + 6 files changed, 65 insertions(+), 62 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs index 602f0b245..0e187e015 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution.Internal; namespace Amazon.Lambda.DurableExecution; diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index 178a10604..f1394c5bf 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -179,25 +179,30 @@ private static bool IsTerminalCheckpointError(AmazonServiceException ex) // The user's input payload is stored inside the service envelope as an EXECUTION-type // operation. This is part of the durable execution wire format — each invocation includes // its input as a checkpoint record so the service can validate replay consistency. + // A missing EXECUTION op is a malformed envelope: surfacing it as a typed exception here + // gives a clear error instead of letting default!/null bubble into user code as an opaque + // NullReferenceException. private static TInput ExtractUserPayload( DurableExecutionInvocationInput input, ILambdaSerializer serializer) { - if (input.InitialExecutionState?.Operations == null) - return default!; - - foreach (var op in input.InitialExecutionState.Operations) + if (input.InitialExecutionState?.Operations != null) { - if (op.Type != OperationTypes.Execution || op.ExecutionDetails?.InputPayload == null) - continue; - - var payload = op.ExecutionDetails.InputPayload; - var bytes = Encoding.UTF8.GetBytes(payload); - using var ms = new MemoryStream(bytes); - return serializer.Deserialize(ms); + foreach (var op in input.InitialExecutionState.Operations) + { + if (op.Type != OperationTypes.Execution || op.ExecutionDetails?.InputPayload == null) + continue; + + var payload = op.ExecutionDetails.InputPayload; + var bytes = Encoding.UTF8.GetBytes(payload); + using var ms = new MemoryStream(bytes); + return serializer.Deserialize(ms); + } } - return default!; + throw new DurableExecutionException( + "Durable execution envelope is malformed: no EXECUTION-type operation with an input payload was found. " + + "The service must include an EXECUTION op carrying the workflow's input on every invocation."); } private static DurableExecutionInvocationOutput MapToOutput( diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs index 08d734d05..f936d3d24 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/ExecutionState.cs @@ -24,6 +24,7 @@ internal sealed class ExecutionState private readonly Dictionary _operations = new(); private readonly HashSet _visitedOperations = new(); private bool _isReplaying; + private int _remainingReplayOps; public int CheckpointedOperationCount => _operations.Count; @@ -44,7 +45,7 @@ public void LoadFromCheckpoint(InitialExecutionState? initialState) // EXECUTION op (input payload bookkeeping) is always present and must // not count — see Python execution.py:258 / Java ExecutionManager:81 / // JS execution-context.ts:62 for the same rule. - _isReplaying = HasReplayableOperations(); + (_isReplaying, _remainingReplayOps) = ScanReplayable(); } public void AddOperations(IEnumerable operations) @@ -79,19 +80,13 @@ public void AddOperations(IEnumerable operations) public void TrackReplay(string operationId) { if (!_isReplaying) return; + if (!_visitedOperations.Add(operationId)) return; + if (!_operations.TryGetValue(operationId, out var op)) return; + if (op.Type == OperationTypes.Execution) return; + if (!IsTerminalStatus(op.Status)) return; - _visitedOperations.Add(operationId); - - // Have we visited every completed non-EXECUTION op? If so, anything - // emitted from here on is fresh execution. - foreach (var op in _operations.Values) - { - if (op.Type == OperationTypes.Execution) continue; - if (!IsTerminalStatus(op.Status)) continue; - if (!_visitedOperations.Contains(op.Id!)) return; - } - - _isReplaying = false; + if (--_remainingReplayOps <= 0) + _isReplaying = false; } public void ValidateReplayConsistency(string operationId, string expectedType, string? expectedName) @@ -117,13 +112,17 @@ public void ValidateReplayConsistency(string operationId, string expectedType, s } } - private bool HasReplayableOperations() + private (bool HasReplayable, int TerminalCount) ScanReplayable() { + var has = false; + var count = 0; foreach (var op in _operations.Values) { - if (op.Type != OperationTypes.Execution) return true; + if (op.Type == OperationTypes.Execution) continue; + has = true; + if (IsTerminalStatus(op.Status)) count++; } - return false; + return (has, count); } private static bool IsTerminalStatus(string? status) => diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs index 9610ca5f4..01d5cccf0 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs @@ -1,14 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Amazon.Lambda.DurableExecution; +namespace Amazon.Lambda.DurableExecution.Internal; /// /// Converts between UPPER_SNAKE_CASE wire format (e.g., CHAINED_INVOKE) /// and PascalCase enum values (e.g., ChainedInvoke). /// -/// -public sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum +internal sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum { /// public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index 1bbc23b47..f30a302de 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -268,10 +268,11 @@ public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() } [Fact] - public async Task WrapAsync_NoExecutionOp_ReceivesDefaultPayload() + public async Task WrapAsync_NoExecutionOp_ThrowsMalformedEnvelope() { - // No EXECUTION operation in the envelope — ExtractUserPayload returns default(TInput). - // Exercises the "loop falls through without finding EXECUTION" branch in DurableFunction.ExtractUserPayload. + // No EXECUTION operation in the envelope — ExtractUserPayload must throw a typed + // DurableExecutionException so the malformed envelope surfaces as a clear error + // instead of leaking default!/null into user code as a NullReferenceException. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:no-exec", @@ -281,20 +282,19 @@ public async Task WrapAsync_NoExecutionOp_ReceivesDefaultPayload() } }; - OrderEvent? observed = null; - var output = await DurableFunction.WrapAsync( - async (evt, ctx) => - { - observed = evt; - await Task.CompletedTask; - return new OrderResult { Status = "ok" }; - }, - input, - CreateLambdaContext(), - _mockClient); + var ex = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + async (evt, ctx) => + { + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + CreateLambdaContext(), + _mockClient)); - Assert.Equal(InvocationStatus.Succeeded, output.Status); - Assert.Null(observed); // default(OrderEvent) for a reference type is null + Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("EXECUTION", ex.Message); } [Fact] @@ -409,28 +409,26 @@ public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() } [Fact] - public async Task WrapAsync_NullInitialExecutionState_ReceivesDefaultPayload() + public async Task WrapAsync_NullInitialExecutionState_ThrowsMalformedEnvelope() { - // No initial execution state at all. Same default-return branch in ExtractUserPayload. + // No initial execution state at all — same malformed-envelope branch in ExtractUserPayload. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:null-state" }; - OrderEvent? observed = null; - var output = await DurableFunction.WrapAsync( - async (evt, ctx) => - { - observed = evt; - await Task.CompletedTask; - return new OrderResult { Status = "ok" }; - }, - input, - CreateLambdaContext(), - _mockClient); + var ex = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + async (evt, ctx) => + { + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + CreateLambdaContext(), + _mockClient)); - Assert.Equal(InvocationStatus.Succeeded, output.Status); - Assert.Null(observed); + Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); } // ────────────────────────────────────────────────────────────────────── diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs index 679a49b6f..d7b2fcdaa 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/UpperSnakeCaseEnumConverterTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.DurableExecution.Internal; using Xunit; namespace Amazon.Lambda.DurableExecution.Tests; From 15c011ed6d8918e56805c5c4beb036f16536761e Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 13:42:41 -0400 Subject: [PATCH 10/16] Add stream-stream + serializer overload to LambdaBootstrapBuilder Adds LambdaBootstrapBuilder.Create(Func>, ILambdaSerializer) and the matching HandlerWrapper.GetHandlerWrapper overload so stream-stream handlers can expose the supplied serializer via ILambdaContext.Serializer. Enables frameworks that own envelope (de)serialization end-to-end but still need to delegate inner-payload (de)serialization to a user-supplied serializer. --- .../e1a240df-673e-4a7d-af74-197103533038.json | 11 +++++++++++ .../Bootstrap/HandlerWrapper.cs | 19 +++++++++++++++++++ .../Bootstrap/LambdaBootstrapBuilder.cs | 15 +++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .autover/changes/e1a240df-673e-4a7d-af74-197103533038.json diff --git a/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json b/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json new file mode 100644 index 000000000..8d0fd5d42 --- /dev/null +++ b/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.RuntimeSupport", + "Type": "Minor", + "ChangelogMessages": [ + "Add LambdaBootstrapBuilder.Create(Func>, ILambdaSerializer) overload (and matching HandlerWrapper.GetHandlerWrapper) so stream-stream handlers can expose a serializer via ILambdaContext.Serializer. Enables frameworks that own envelope (de)serialization but delegate inner-payload (de)serialization to a user-supplied serializer." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs index 1981d5509..4d494413c 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs @@ -259,6 +259,25 @@ public static HandlerWrapper GetHandlerWrapper(Func + /// Get a HandlerWrapper for a stream-stream handler that also wants the supplied + /// exposed via . + /// The serializer is not used to (de)serialize the input/output streams — the handler + /// owns those — it is only made available on the context for handlers that perform + /// their own envelope (de)serialization and need to delegate an inner payload to a + /// user-supplied serializer. + /// + /// Func called for each invocation of the Lambda function. + /// ILambdaSerializer made available via . + /// A HandlerWrapper + public static HandlerWrapper GetHandlerWrapper(Func> handler, ILambdaSerializer serializer) + { + return new HandlerWrapper(async (invocation) => + { + return new InvocationResponse(await handler(invocation.InputStream, invocation.LambdaContext)); + }) { Serializer = serializer }; + } + /// /// Get a HandlerWrapper that will call the given method on function invocation. /// Note that you may have to cast your handler to its specific type to help the compiler. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs index fff7710ca..33c500256 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs @@ -162,6 +162,21 @@ public static LambdaBootstrapBuilder Create(Func + /// Create a builder for creating the LambdaBootstrap. Use this overload for handlers + /// that consume and produce raw s but still want the supplied + /// exposed via + /// — useful for frameworks that perform their own envelope (de)serialization and + /// invoke the user's serializer for an inner payload. + /// + /// The handler that will be called for each Lambda invocation + /// The Lambda serializer made available via . Not used to (de)serialize the input/output streams themselves. + /// + public static LambdaBootstrapBuilder Create(Func> handler, ILambdaSerializer serializer) + { + return new LambdaBootstrapBuilder(HandlerWrapper.GetHandlerWrapper(handler, serializer)); + } + /// /// Create a builder for creating the LambdaBootstrap. /// From 3a596371e4662b5b0b764579bfc9c111bbd4248f Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 13:46:17 -0400 Subject: [PATCH 11/16] serialization updates --- Docs/durable-execution-design.md | 276 +++++++----------- .../DurableContext.cs | 6 +- .../DurableEntryPoint.cs | 112 +++++++ .../DurableExecutionInvocationInput.cs | 17 +- .../DurableExecutionInvocationOutput.cs | 7 +- .../Amazon.Lambda.DurableExecution/Enums.cs | 5 +- .../ErrorObject.cs | 5 +- .../IDurableContext.cs | 8 +- .../DurableEntryPointCore.cs} | 90 +----- .../Internal/DurableEnvelopeJsonContext.cs | 15 + .../Internal/InvocationStatusConverter.cs | 8 + .../Internal/UpperSnakeCaseEnumConverter.cs | 2 +- .../Program.cs | 24 +- .../AotWaitOnlyTest.cs | 56 ++++ .../DurableFunctionDeployment.cs | 165 +++++++++-- .../LongerWaitFunction/Function.cs | 20 +- .../MultipleStepsFunction/Function.cs | 20 +- .../ReplayDeterminismFunction/Function.cs | 20 +- .../StepFailsFunction/Function.cs | 20 +- .../StepWaitStepFunction/Function.cs | 20 +- .../WaitOnlyAotFunction/Dockerfile | 42 +++ .../WaitOnlyAotFunction/Function.cs | 34 +++ .../WaitOnlyAotFunction.csproj | 25 ++ .../WaitOnlyFunction/Function.cs | 20 +- ...tionTests.cs => DurableEntryPointTests.cs} | 212 ++++++++------ 25 files changed, 753 insertions(+), 476 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs rename Libraries/src/Amazon.Lambda.DurableExecution/{DurableFunction.cs => Internal/DurableEntryPointCore.cs} (64%) create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj rename Libraries/test/Amazon.Lambda.DurableExecution.Tests/{DurableFunctionTests.cs => DurableEntryPointTests.cs} (77%) diff --git a/Docs/durable-execution-design.md b/Docs/durable-execution-design.md index 6df424c5f..64f903e02 100644 --- a/Docs/durable-execution-design.md +++ b/Docs/durable-execution-design.md @@ -79,11 +79,13 @@ Your function reads like a normal async method. The SDK deals with state, replay Durable functions use a replay-based execution model. Every invocation runs your code from the top, but previously completed steps return their cached result instead of re-executing. -1. Lambda invokes your function with a `DurableExecutionInvocationInput` containing: +1. Lambda invokes your function with a service-envelope payload containing: - `DurableExecutionArn` -- unique execution identifier - `CheckpointToken` -- for optimistic concurrency - `InitialExecutionState` -- previously checkpointed operations + The SDK reads this envelope, hands your workflow only the user payload, and writes the response envelope on the way out — your code never sees the wire format. + 2. Your function code runs **from the beginning** on every invocation. 3. When a **step** is encountered: @@ -190,23 +192,28 @@ Things to notice: #### Manual Handler (Without Annotations) -If you don't use `Amazon.Lambda.Annotations`, use `DurableFunction.WrapAsync` — a static helper (inspired by [OpenTelemetry's `AWSLambdaWrapper.TraceAsync`](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/tree/main/src/OpenTelemetry.Instrumentation.AWSLambda#lambda-function)) that handles the entire durable execution envelope for you: +If you don't use `Amazon.Lambda.Annotations`, register `DurableEntryPoint` as your Lambda handler. The entry point owns all wire-envelope (de)serialization — your workflow only deals with `TInput`/`TOutput`: ```csharp -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; - -[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; namespace MyDurableFunction; public class Function { - public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); + private static readonly DurableEntryPoint _entry = new(MyWorkflow); + + public static async Task Main() + { + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); + } - private async Task MyWorkflow(OrderEvent input, IDurableContext context) + private static async Task MyWorkflow(OrderEvent input, IDurableContext context) { var validation = await context.StepAsync( async (step) => await ValidateOrder(input.OrderId), @@ -224,26 +231,23 @@ public class Function return new OrderResult { Status = "approved", OrderId = result.OrderId }; } - private async Task ValidateOrder(string orderId) { /* ... */ } - private async Task ProcessOrder(string orderId) { /* ... */ } + private static async Task ValidateOrder(string orderId) { /* ... */ } + private static async Task ProcessOrder(string orderId) { /* ... */ } } ``` -`DurableFunction.WrapAsync` handles all the plumbing: -- Hydrates `ExecutionState` from `invocationInput.InitialExecutionState` -- Extracts the user payload from the service envelope -- Runs the workflow through `DurableExecutionHandler.RunAsync` -- Constructs and returns the `DurableExecutionInvocationOutput` envelope (status mapping, JSON serialization) -- Sets execution environment tracking +`DurableEntryPoint.InvokeAsync` is a `Stream → Stream` Lambda handler. It: +- Deserializes the service envelope using a library-internal `JsonSerializerContext` +- Hydrates `ExecutionState` from `InitialExecutionState` +- Extracts the user payload via the registered `ILambdaSerializer` and runs your workflow through `DurableExecutionHandler.RunAsync` +- Serializes the result using the registered `ILambdaSerializer`, wraps it in the response envelope, and writes the envelope back to the stream -For workflows that return no value, use the single-type-parameter overload: +For workflows that return no value, use the single-type-parameter form: ```csharp -public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); +private static readonly DurableEntryPoint _entry = new(MyWorkflow); -private async Task MyWorkflow(OrderEvent input, IDurableContext context) +private static async Task MyWorkflow(OrderEvent input, IDurableContext context) { await context.StepAsync(async (step) => await SendNotification(input.UserId), name: "notify"); await context.WaitAsync(TimeSpan.FromHours(1), name: "cooldown"); @@ -251,41 +255,37 @@ private async Task MyWorkflow(OrderEvent input, IDurableContext context) } ``` -For **NativeAOT** deployments, pass a `JsonSerializerContext` so the SDK can serialize/deserialize your input and output types without reflection: +For **NativeAOT** deployments, register a `SourceGeneratorLambdaJsonSerializer` whose `JsonSerializerContext` lists only your own types. The library's internal envelope context handles the wire format — users never register envelope types, so no source-gen warnings or accessibility errors: ```csharp [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] -internal partial class MyJsonContext : JsonSerializerContext { } +public partial class MyJsonContext : JsonSerializerContext { } public class Function { - public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync( - MyWorkflow, invocationInput, context, MyJsonContext.Default); + private static readonly DurableEntryPoint _entry = new(MyWorkflow); + + public static async Task Main() + { + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } - private async Task MyWorkflow(OrderEvent input, IDurableContext context) + private static async Task MyWorkflow(OrderEvent input, IDurableContext context) { // ... } } ``` -To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), use the overload that accepts one: +To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), pass it to the `DurableEntryPoint` constructor: ```csharp -public class Function -{ - private readonly IAmazonLambda _lambdaClient; - - public Function(IAmazonLambda lambdaClient) => _lambdaClient = lambdaClient; - - public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync( - MyWorkflow, invocationInput, context, _lambdaClient); -} +private static readonly DurableEntryPoint _entry = + new(MyWorkflow, new AmazonLambdaClient(/* custom config */)); ``` You'd also need to manually configure the CloudFormation template with `DurableConfig` and managed policies: @@ -296,7 +296,7 @@ You'd also need to manually configure the CloudFormation template with `DurableC "MyFunction": { "Type": "AWS::Serverless::Function", "Properties": { - "Handler": "MyDurableFunction::MyDurableFunction.Function::FunctionHandler", + "Handler": "MyDurableFunction", "Policies": [ "AWSLambdaBasicExecutionRole", "AWSLambdaBasicDurableExecutionRolePolicy" @@ -310,46 +310,23 @@ You'd also need to manually configure the CloudFormation template with `DurableC } ``` -##### What WrapAsync does internally +##### Two-stage (de)serialization -For reference, here's the expanded version of what `DurableFunction.WrapAsync` eliminates — this is effectively what the source generator produces for the Annotations path: +The reason `DurableEntryPoint` exists — instead of a typed `(EnvelopeIn, ILambdaContext) → EnvelopeOut` handler — is to keep the wire envelope and its internal types out of the user's `JsonSerializerContext`. Under AOT/source-gen JSON, the user's context lives in a different assembly than the library, so it can't see internal envelope types or attribute-referenced internal converters. Splitting (de)serialization into two stages avoids the leak entirely: -```csharp -public async Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext) -{ - // 1. Hydrate execution state from previously checkpointed operations - var state = new ExecutionState(); - state.LoadFromCheckpoint(invocationInput.InitialExecutionState); +| Stage | Owner | Reads/writes | Context used | +|---|---|---|---| +| 1. Envelope | Library | `Stream` ↔ `DurableExecutionInvocationInput`/`Output` | Internal `DurableEnvelopeJsonContext` (sees all internal types) | +| 2. User payload | User | `string` ↔ `TInput`/`TOutput` | The `ILambdaSerializer` you register with `LambdaBootstrapBuilder` | - // 2. Extract user payload from the service envelope (internal) - var userPayload = ExtractUserPayload(invocationInput); - - // 3. Run the user's workflow via DurableExecutionHandler.RunAsync - var result = await DurableExecutionHandler.RunAsync( - state, - async (durableContext) => await MyWorkflow(userPayload, durableContext), - invocationInput.DurableExecutionArn); - - // 4. Construct and return the service output envelope - return new DurableExecutionInvocationOutput - { - Status = result.Status, - Result = result.Status == InvocationStatus.Succeeded - ? JsonSerializer.Serialize(result.Result) - : null, - ErrorMessage = result.Message - }; -} -``` +The user's serializer is read from `ILambdaContext.Serializer` at invocation time (the new `LambdaBootstrapBuilder.Create(Func>, ILambdaSerializer)` overload propagates it). With AOT this means the user only registers their own POCOs; the library's envelope types stay internal. -Key differences between `WrapAsync` and the Annotations approach: -- `WrapAsync` still requires you to define the Lambda entry point signature (`DurableExecutionInvocationInput` → `DurableExecutionInvocationOutput`) +Differences vs the Annotations approach: +- You define `Main` and call `LambdaBootstrapBuilder` yourself - You configure `DurableConfig` + managed policies in your CloudFormation template manually (not generated) - No `[LambdaFunction]` or `[DurableExecution]` attributes needed -With `[LambdaFunction] + [DurableExecution]`, even the entry point and CloudFormation config are generated at compile time — you just write the workflow method. +With `[LambdaFunction] + [DurableExecution]`, even the `Main` entry point and CloudFormation config are generated at compile time — you just write the workflow method. --- @@ -932,110 +909,50 @@ When user code hits a pending wait or callback: ## API Reference -### DurableFunction +### DurableEntryPoint -Static helper for the non-Annotations handler path. Wraps a workflow function, handling all envelope translation between `DurableExecutionInvocationInput`/`DurableExecutionInvocationOutput` and user types. +The non-Annotations Lambda handler. Reads the wire envelope from a `Stream`, runs the workflow, and writes the response envelope back. Same shape regardless of JIT or AOT — the only thing that varies is the `ILambdaSerializer` you register with `LambdaBootstrapBuilder`. ```csharp /// -/// Static helper that wraps a durable workflow function, handling all envelope -/// translation between DurableExecutionInvocationInput/Output and user types. -/// Inspired by OpenTelemetry.Instrumentation.AWSLambda's AWSLambdaWrapper.TraceAsync pattern. +/// AOT-friendly entry point for a durable workflow. Owns (de)serialization of +/// the wire envelope so users only register their own POCO types in their +/// JsonSerializerContext. /// -public static class DurableFunction +public sealed class DurableEntryPoint { - // ── Reflection-based overloads (JIT only) ────────────────────────── - - /// - /// Wrap a workflow that takes typed input and returns typed output. - /// Reflection-based JSON — not AOT-safe. - /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext); - /// - /// Wrap a workflow (typed input + output) with explicit Lambda client. - /// Reflection-based JSON — not AOT-safe. + /// Uses a default AmazonLambdaClient, constructed lazily and cached process-wide. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient); + public DurableEntryPoint(Func> workflow); /// - /// Wrap a void workflow (typed input, no output). - /// Reflection-based JSON — not AOT-safe. + /// Uses the supplied IAmazonLambda for checkpoint and state-fetch calls. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext); + public DurableEntryPoint(Func> workflow, IAmazonLambda lambdaClient); /// - /// Wrap a void workflow with explicit Lambda client. - /// Reflection-based JSON — not AOT-safe. + /// Lambda handler entry point. Register with LambdaBootstrapBuilder alongside + /// an ILambdaSerializer that knows how to (de)serialize TInput/TOutput. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient); - - // ── AOT-safe overloads (caller supplies JsonSerializerContext) ────── - - /// - /// Wrap a workflow (typed input + output). AOT-safe — requires - /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] - /// on the supplied jsonContext. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext); - - /// - /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext); - - /// - /// Wrap a void workflow (typed input, no output). AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext); + public Task InvokeAsync(Stream input, ILambdaContext context); +} - /// - /// Wrap a void workflow with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext); +/// +/// AOT-friendly entry point for a void durable workflow. +/// +public sealed class DurableEntryPoint +{ + public DurableEntryPoint(Func workflow); + public DurableEntryPoint(Func workflow, IAmazonLambda lambdaClient); + public Task InvokeAsync(Stream input, ILambdaContext context); } ``` +`DurableEntryPoint.InvokeAsync` requires an `ILambdaSerializer` to be registered on `ILambdaContext.Serializer`; if it's null, the entry point throws `InvalidOperationException` with a message pointing to `LambdaBootstrapBuilder.Create(handler, serializer)`. In tests, set `TestLambdaContext.Serializer` directly. + +The wire-envelope types (`DurableExecutionInvocationInput`/`Output`, `InvocationStatus`, `ErrorObject`) are intentionally `internal` — user code never constructs or reads them. + ### IDurableContext > **Implementations:** [Python](https://github.com/aws/aws-durable-execution-sdk-python/blob/main/src/aws_durable_execution_sdk_python/context.py) | [JavaScript](https://github.com/aws/aws-durable-execution-sdk-js/blob/main/packages/aws-durable-execution-sdk-js/src/types/durable-context.ts) @@ -1713,11 +1630,11 @@ Both approaches produce a self-contained executable that the Lambda custom runti ### NativeAOT compatibility -The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. For NativeAOT deployments, AOT safety is addressed at two levels — **at each level there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` and an AOT-safe one that requires a serializer parameter**. The trimmer warns at the call site when reflection overloads are used in AOT/trimmed builds. +The SDK is AOT-friendly but does not require AOT. AOT safety is addressed at two levels: -1. **Entry point (`DurableFunction.WrapAsync`)** — the AOT-safe overload takes a `JsonSerializerContext` parameter that includes type info for your `TInput` and `TOutput` types. +1. **Entry point** — `DurableEntryPoint` owns wire-envelope (de)serialization through an internal `JsonSerializerContext`. The user-supplied `ILambdaSerializer` (registered with `LambdaBootstrapBuilder`) only handles `TInput`/`TOutput`. For AOT, register a `SourceGeneratorLambdaJsonSerializer` whose context lists only your own POCOs — envelope types stay private to the library and never need to appear in the user's context. -2. **Step checkpoints (`IDurableContext.StepAsync`)** — the AOT-safe overload takes an `ICheckpointSerializer` directly as a parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. +2. **Step checkpoints (`IDurableContext.StepAsync`)** — there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]`, and an AOT-safe one that takes an `ICheckpointSerializer` parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. @@ -1725,16 +1642,22 @@ The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other re // Default: works with reflection (JIT mode); flagged for AOT. var result = await context.StepAsync(async (step) => await GetOrder()); -// AOT mode — entry point: pass JsonSerializerContext to WrapAsync. +// AOT mode — entry point: register a source-generated ILambdaSerializer. +// Only your own types appear in the context — no envelope types. [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] [JsonSerializable(typeof(Order))] -internal partial class MyJsonContext : JsonSerializerContext { } +public partial class MyJsonContext : JsonSerializerContext { } -public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync( - MyWorkflow, invocationInput, context, MyJsonContext.Default); +private static readonly DurableEntryPoint _entry = new(MyWorkflow); + +public static async Task Main() +{ + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); +} // AOT mode — step checkpoint: pass ICheckpointSerializer to StepAsync directly. var result = await context.StepAsync( @@ -1756,7 +1679,7 @@ The SDK handles overflow transparently: **Batch results (map/parallel) exceeding limits:** For large map/parallel operations, the SDK generates a compact summary for the parent operation's checkpoint. The summary includes item count, success/failure counts, and completion reason — but not individual item results. During replay, the SDK sets `ReplayChildren = true` on the state request, which causes the service to return child operation records so full results can be reconstructed. -**Lambda response exceeding 6 MB:** If the final orchestration result exceeds the response payload limit, the SDK checkpoints the result before returning the `DurableExecutionInvocationOutput`. The service reads the result from the checkpoint rather than from the response body. +**Lambda response exceeding 6 MB:** If the final orchestration result exceeds the response payload limit, the SDK checkpoints the result before returning the response envelope. The service reads the result from the checkpoint rather than from the response body. **Guidance for very large results:** For results that are inherently large (multi-MB payloads), use a custom `ICheckpointSerializer` that offloads to external storage (S3, DynamoDB) and returns a reference. This keeps checkpoint sizes small and avoids pagination overhead: @@ -1795,9 +1718,10 @@ The SDK uses existing Lambda core interfaces: The durable execution handler integrates with the existing runtime support bootstrap: ```csharp -// The [DurableExecution] attribute signals that the handler -// receives DurableExecutionInvocationInput and returns DurableExecutionInvocationOutput -// The SDK handles the translation to/from the user's handler signature +// The [DurableExecution] attribute signals that the handler is a durable workflow. +// The Annotations source generator emits a Main method that registers a +// DurableEntryPoint with LambdaBootstrapBuilder. The wire envelope +// is invisible to the user's handler — they receive TInput and return TOutput. ``` ### Amazon.Lambda.Annotations (optional) @@ -1806,7 +1730,7 @@ The durable execution handler integrates with the existing runtime support boots When both packages are referenced, the Annotations source generator detects `[DurableExecution]` by fully-qualified name and at compile time: -1. Generates a handler wrapper that translates `DurableExecutionInvocationInput` to/from your types +1. Generates a `Main` entry point that wires up `DurableEntryPoint` for your workflow 2. Manages context lifecycle (creation, checkpoint batching, cleanup) 3. Adds `DurableConfig` to the CloudFormation template 4. Adds the `AWSLambdaBasicDurableExecutionRolePolicy` managed policy @@ -1853,7 +1777,7 @@ public class Functions } ``` -When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path (`DurableFunction.WrapAsync`), pass the client directly via the `IAmazonLambda lambdaClient` overload. +When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path, pass the client to the `DurableEntryPoint` constructor. > **Dependency boundaries:** `Amazon.Lambda.Annotations` has **no dependency** on the AWS SDK or on `Amazon.Lambda.DurableExecution`. The Annotations source generator references durable execution types by fully-qualified name strings only — it never takes a compile-time dependency on the durable package. The `[DurableExecution]` attribute is defined in `Amazon.Lambda.DurableExecution`, and the generated code resolves against the user's project references. There is only one source generator (Annotations) — no coordination between multiple generators is needed. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs index e01a26604..e79cee30b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -69,8 +69,10 @@ private Task RunStep( var serializer = LambdaContext.Serializer ?? throw new InvalidOperationException( "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + - "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + - "(or in tests, set TestLambdaContext.Serializer)."); + "In the class library programming model, register one with " + + "[assembly: LambdaSerializer(typeof(...))]. In an executable / custom " + + "runtime, pass it to LambdaBootstrapBuilder.Create(handler, serializer). " + + "In tests, set TestLambdaContext.Serializer."); var operationId = _idGenerator.NextId(); var op = new StepOperation( diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs new file mode 100644 index 000000000..1fbff7e8b --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs @@ -0,0 +1,112 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using Amazon.Lambda; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution.Internal; +using Amazon.Lambda.DurableExecution.Services; +using Amazon.Lambda.Model; +using Amazon.Runtime; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// AOT-friendly entry point for a durable workflow. Owns (de)serialization of +/// the wire envelope so users only register their own POCO types in their +/// JsonSerializerContext — the library's +/// handles envelope JSON, the user's (read from +/// ) handles only +/// and . +/// +/// The workflow's input payload type. +/// The workflow's return type. +/// +/// +/// private static readonly DurableEntryPoint<OrderEvent, OrderResult> _entry = new(MyWorkflow); +/// +/// static async Task Main() +/// { +/// await LambdaBootstrapBuilder +/// .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer<MyJsonContext>()) +/// .Build() +/// .RunAsync(); +/// } +/// +/// +public sealed class DurableEntryPoint +{ + private static readonly Lazy _cachedLambdaClient = + new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); + + private readonly Func> _workflow; + private readonly IAmazonLambda _lambdaClient; + + /// + /// Creates an entry point that uses a default , + /// constructed lazily and cached process-wide. + /// + public DurableEntryPoint(Func> workflow) + : this(workflow, _cachedLambdaClient.Value) + { + } + + /// + /// Creates an entry point that uses the supplied client + /// for checkpoint and state-fetch calls. + /// + public DurableEntryPoint(Func> workflow, IAmazonLambda lambdaClient) + { + _workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + _lambdaClient = lambdaClient ?? throw new ArgumentNullException(nameof(lambdaClient)); + } + + /// + /// Lambda handler entry point. Register this method with LambdaBootstrapBuilder + /// alongside an that knows how to (de)serialize + /// / . + /// + public async Task InvokeAsync(Stream input, ILambdaContext context) + { + var output = await DurableEntryPointCore.InvokeAsync(_workflow, input, context, _lambdaClient); + var ms = new MemoryStream(); + JsonSerializer.Serialize(ms, output, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput); + ms.Position = 0; + return ms; + } +} + +/// +/// AOT-friendly entry point for a void durable workflow. +/// See for details. +/// +public sealed class DurableEntryPoint +{ + private readonly DurableEntryPoint _inner; + + /// + /// Creates an entry point that uses a default , + /// constructed lazily and cached process-wide. + /// + public DurableEntryPoint(Func workflow) + { + if (workflow == null) throw new ArgumentNullException(nameof(workflow)); + _inner = new DurableEntryPoint(async (i, c) => { await workflow(i, c); return null; }); + } + + /// + /// Creates an entry point that uses the supplied client + /// for checkpoint and state-fetch calls. + /// + public DurableEntryPoint(Func workflow, IAmazonLambda lambdaClient) + { + if (workflow == null) throw new ArgumentNullException(nameof(workflow)); + _inner = new DurableEntryPoint( + async (i, c) => { await workflow(i, c); return null; }, + lambdaClient); + } + + /// + public Task InvokeAsync(Stream input, ILambdaContext context) + => _inner.InvokeAsync(input, context); +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs index 35bc32ecd..6d1dc7acf 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs @@ -5,9 +5,10 @@ namespace Amazon.Lambda.DurableExecution; /// /// The service envelope input for a durable execution invocation. -/// This is what Lambda receives from the durable execution service. +/// owns (de)serialization +/// end-to-end so users only register their own POCO types. /// -public sealed class DurableExecutionInvocationInput +internal sealed class DurableExecutionInvocationInput { /// /// The unique ARN identifying this durable execution. @@ -22,15 +23,13 @@ public sealed class DurableExecutionInvocationInput public string? CheckpointToken { get; set; } /// - /// Previously checkpointed operation state for replay. Internal — consumed - /// only by DurableFunction.WrapAsync for replay correlation; user code - /// should never read or modify this. Marked - /// so System.Text.Json populates it during deserialization despite being internal - /// (framework needs it, but it's not part of the public API contract). + /// Previously checkpointed operation state for replay. Declared public + /// so emits a setter — STJ + /// source-gen reads declared accessibility, not effective accessibility, and + /// silently skips internal-declared members even within the same assembly. /// [JsonPropertyName("InitialExecutionState")] - [JsonInclude] - internal InitialExecutionState? InitialExecutionState { get; set; } + public InitialExecutionState? InitialExecutionState { get; set; } } /// diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs index 0e187e015..3527cc16c 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using System.Text.Json.Serialization; using Amazon.Lambda.DurableExecution.Internal; @@ -6,14 +5,16 @@ namespace Amazon.Lambda.DurableExecution; /// /// The service envelope output returned by a durable execution invocation. +/// Written by directly to the +/// Lambda response stream. /// -public sealed class DurableExecutionInvocationOutput +internal sealed class DurableExecutionInvocationOutput { /// /// The terminal status of this invocation. /// [JsonPropertyName("Status")] - [JsonConverter(typeof(UpperSnakeCaseEnumConverter))] + [JsonConverter(typeof(InvocationStatusConverter))] public required InvocationStatus Status { get; set; } /// diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs index c1bf44403..73461e12b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs @@ -1,9 +1,10 @@ namespace Amazon.Lambda.DurableExecution; /// -/// The terminal status of a durable execution invocation. +/// The terminal status of a durable execution invocation. Appears on the wire +/// envelope and on HandlerResult. /// -public enum InvocationStatus +internal enum InvocationStatus { /// The workflow completed successfully. Succeeded, diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs index 20acac47f..480e55813 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs @@ -3,9 +3,10 @@ namespace Amazon.Lambda.DurableExecution; /// -/// Serializable error representation stored in checkpoint state. +/// Serializable error representation stored in checkpoint state. Produced by the +/// entry point when a workflow throws and shipped on the wire envelope. /// -public sealed class ErrorObject +internal sealed class ErrorObject { /// /// The fully-qualified exception type name. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs index 581b02a94..fb49d9e01 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs @@ -32,11 +32,9 @@ public interface IDurableContext /// /// Execute a step with automatic checkpointing. The step result is serialized /// to a checkpoint using the registered on - /// (typically configured via - /// LambdaBootstrapBuilder.Create(handler, serializer)). AOT and - /// reflection-based scenarios share this single overload — the AOT story is - /// determined by the registered serializer (e.g., - /// SourceGeneratorLambdaJsonSerializer<TContext>). + /// . AOT and reflection-based scenarios + /// share this single overload — the AOT story is determined by the registered + /// serializer (e.g., SourceGeneratorLambdaJsonSerializer<TContext>). /// Task StepAsync( Func> func, diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs similarity index 64% rename from Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs index f1394c5bf..3e341146b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs @@ -1,82 +1,37 @@ using System.IO; using System.Text; -using System.Threading; +using System.Text.Json; using Amazon.Lambda; using Amazon.Lambda.Core; -using Amazon.Lambda.DurableExecution.Internal; using Amazon.Lambda.DurableExecution.Services; using Amazon.Lambda.Model; using Amazon.Runtime; -namespace Amazon.Lambda.DurableExecution; +namespace Amazon.Lambda.DurableExecution.Internal; /// -/// Static helper that wraps a durable workflow function, handling all envelope -/// translation between DurableExecutionInvocationInput/Output and user types. -/// -/// All four overloads dispatch through the registered -/// on , so AOT-safe and reflection-based -/// callers share a single code path. Callers wire AOT support by registering an -/// AOT-aware serializer with the runtime -/// (e.g., SourceGeneratorLambdaJsonSerializer<TContext>) — no per-call -/// JsonSerializerContext argument is required. +/// Shared orchestration body for . +/// Reads the envelope with the library context, runs the workflow with the user's +/// serializer for TInput/TOutput, returns the populated output envelope. /// -public static class DurableFunction +internal static class DurableEntryPointCore { - private static readonly Lazy _cachedLambdaClient = - new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); - - /// - /// Wrap a workflow (typed input + output). - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); - - /// - /// Wrap a workflow (typed input + output) with explicit Lambda client. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient); - - /// - /// Wrap a void workflow (typed input, no output). - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext) - => WrapAsync(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); - - /// - /// Wrap a void workflow with explicit Lambda client. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient) - => WrapAsyncCore( - async (input, ctx) => { await workflow(input, ctx); return null; }, - invocationInput, lambdaContext, lambdaClient); - - private static async Task WrapAsyncCore( + public static async Task InvokeAsync( Func> workflow, - DurableExecutionInvocationInput invocationInput, + Stream input, ILambdaContext lambdaContext, IAmazonLambda lambdaClient) { var serializer = lambdaContext.Serializer ?? throw new InvalidOperationException( "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + - "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + - "(or in tests, set TestLambdaContext.Serializer)."); + "In the class library programming model, register one with " + + "[assembly: LambdaSerializer(typeof(...))]. In an executable / custom " + + "runtime, pass it to LambdaBootstrapBuilder.Create(handler, serializer). " + + "In tests, set TestLambdaContext.Serializer."); + + var invocationInput = JsonSerializer.Deserialize(input, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationInput) + ?? throw new DurableExecutionException("Durable execution envelope is malformed: input stream produced a null envelope."); var state = new ExecutionState(); state.LoadFromCheckpoint(invocationInput.InitialExecutionState); @@ -144,21 +99,6 @@ private static async Task WrapAsyncCoreInvalidParameterValueException with a message starting with /// "Invalid Checkpoint Token" is treated as transient — the service rejects a /// stale token but a retry with a fresh token will succeed. - /// - /// Only checkpoint-flush errors flow through this catch. There are two paths: - /// 1. A flush triggered synchronously from inside a user StepAsync call - /// (the user awaits EnqueueAsync → batch flush → SDK throws → service client - /// wraps). - /// 2. The final after the workflow returns. - /// - /// State-hydration errors (GetExecutionStateAsync) propagate as - /// too, but they are NOT caught here — they - /// flow up to the host so Lambda retries, matching Python's GetExecutionStateError - /// (which extends InvocationError). - /// - /// User-code SDK errors (e.g. an SDK call inside a Step body) are caught by - /// StepRunner and surfaced as StepException for the workflow's normal - /// step-failure handling. /// private static bool IsTerminalCheckpointError(AmazonServiceException ex) { diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs new file mode 100644 index 000000000..8f5a3080e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Source-generated JSON context for the durable execution wire envelope. +/// Co-located with the envelope types so the source generator can see every +/// internal type the envelope reaches (operation details, status converter) — +/// user-side contexts cannot, which is why envelope (de)serialization stays +/// inside the library and the user's serializer is only invoked for +/// TInput/TOutput. +/// +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +internal partial class DurableEnvelopeJsonContext : JsonSerializerContext { } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs new file mode 100644 index 000000000..d6f2eb9d6 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs @@ -0,0 +1,8 @@ +namespace Amazon.Lambda.DurableExecution.Internal; + +/// +/// Concrete subclass for . Source-generator JSON +/// contexts can only instantiate converters that are concrete and parameterless +/// when referenced via [JsonConverter(typeof(...))]. +/// +internal sealed class InvocationStatusConverter : UpperSnakeCaseEnumConverter { } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs index 01d5cccf0..a349a2ced 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs @@ -7,7 +7,7 @@ namespace Amazon.Lambda.DurableExecution.Internal; /// Converts between UPPER_SNAKE_CASE wire format (e.g., CHAINED_INVOKE) /// and PascalCase enum values (e.g., ChainedInvoke). /// -internal sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum +internal class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum { /// public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs index 2b846bff1..abb5cef36 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -8,27 +7,26 @@ namespace Amazon.Lambda.DurableExecution.AotPublishTest; /// /// AOT publish smoke check. This program must publish under NativeAOT with -/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). The serializer -/// registered with is the same one DurableExecution -/// reads via , so AOT-safety is fully determined -/// by the user's choice of serializer (here, ). +/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). +/// +/// The user-side intentionally registers ONLY the +/// workflow's input/output POCOs — no DurableExecutionInvocation* wire types. +/// Envelope (de)serialization is owned by the library's internal context, so any +/// internal-type leak from the public API surface would cause this project to fail +/// AOT publish (CS0053 / SYSLIB1218 / SYSLIB1220). /// public class Program { + private static readonly DurableEntryPoint _entry = new(WorkflowAsync); + public static async Task Main() { - var serializer = new SourceGeneratorLambdaJsonSerializer(); - Func> handler = HandlerAsync; await LambdaBootstrapBuilder - .Create(handler, serializer) + .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) .Build() .RunAsync(); } - public static Task HandlerAsync( - DurableExecutionInvocationInput input, ILambdaContext context) => - DurableFunction.WrapAsync(WorkflowAsync, input, context); - private static async Task WorkflowAsync(OrderEvent input, IDurableContext context) { var validation = await context.StepAsync( @@ -61,8 +59,6 @@ public class ValidationResult } } -[JsonSerializable(typeof(DurableExecutionInvocationInput))] -[JsonSerializable(typeof(DurableExecutionInvocationOutput))] [JsonSerializable(typeof(Program.OrderEvent))] [JsonSerializable(typeof(Program.OrderResult))] [JsonSerializable(typeof(Program.ValidationResult))] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs new file mode 100644 index 000000000..890bcd4e5 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs @@ -0,0 +1,56 @@ +using System.Linq; +using System.Text; +using Amazon.Lambda.Model; +using Xunit; +using Xunit.Abstractions; + +namespace Amazon.Lambda.DurableExecution.IntegrationTests; + +/// +/// Same wait-only workflow as , but the function image is +/// built with NativeAOT (PublishAot=true, SourceGeneratorLambdaJsonSerializer). Catches +/// regressions where DurableExecution code is JIT-safe but breaks when trimmed/AOT-compiled. +/// +public class AotWaitOnlyTest +{ + private readonly ITestOutputHelper _output; + public AotWaitOnlyTest(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task WaitOnly_NativeAot() + { + await using var deployment = await DurableFunctionDeployment.CreateAsync( + DurableFunctionDeployment.FindTestFunctionDir("WaitOnlyAotFunction"), + "waitonlyaot", _output, useDockerPublish: true); + + var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "wait-only-aot"}"""); + var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); + _output.WriteLine($"Response: {responsePayload}"); + + var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); + Assert.NotNull(arn); + + var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); + Assert.Equal("SUCCEEDED", status, ignoreCase: true); + + var history = await deployment.WaitForHistoryAsync( + arn!, + h => (h.Events?.Any(e => e.WaitSucceededDetails != null) ?? false), + TimeSpan.FromSeconds(60)); + var events = history.Events ?? new List(); + + var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "only_wait"); + Assert.NotNull(waitStarted); + Assert.Equal(5, waitStarted!.WaitStartedDetails.Duration); + + var waitSucceeded = events.FirstOrDefault(e => e.WaitSucceededDetails != null && e.Name == "only_wait"); + Assert.NotNull(waitSucceeded); + + Assert.Empty(events.Where(e => e.StepStartedDetails != null)); + + var invocations = events.Where(e => e.InvocationCompletedDetails != null).ToList(); + Assert.True( + invocations.Count >= 2, + $"Expected at least 2 InvocationCompleted events (initial + post-wait resume), got {invocations.Count}"); + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs index 8b5bb2e1b..783175d7a 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs @@ -52,12 +52,13 @@ private DurableFunctionDeployment(ITestOutputHelper output, string suffix) public static async Task CreateAsync( string testFunctionDir, string scenarioSuffix, - ITestOutputHelper output) + ITestOutputHelper output, + bool useDockerPublish = false) { var deployment = new DurableFunctionDeployment(output, scenarioSuffix); try { - await deployment.InitializeAsync(testFunctionDir); + await deployment.InitializeAsync(testFunctionDir, useDockerPublish); } catch { @@ -69,7 +70,7 @@ public static async Task CreateAsync( return deployment; } - private async Task InitializeAsync(string testFunctionDir) + private async Task InitializeAsync(string testFunctionDir, bool useDockerPublish) { // 1. Create IAM role _output.WriteLine($"Creating IAM role: {_roleName}"); @@ -117,7 +118,7 @@ await _iamClient.AttachRolePolicyAsync(new AttachRolePolicyRequest // 3. Build and push Docker image _output.WriteLine($"Building and pushing Docker image from {testFunctionDir}..."); - _imageUri = await BuildAndPushImage(testFunctionDir, repositoryUri); + _imageUri = await BuildAndPushImage(testFunctionDir, repositoryUri, useDockerPublish); _output.WriteLine($"Image pushed: {_imageUri}"); // 4. Create Lambda function @@ -307,38 +308,148 @@ private async Task WaitForFunctionActive() throw new TimeoutException("Function did not become Active within 120 seconds"); } - private async Task BuildAndPushImage(string testFunctionDir, string repositoryUri) + private async Task BuildAndPushImage(string testFunctionDir, string repositoryUri, bool useDockerPublish) { - var publishDir = Path.Combine(testFunctionDir, "bin", "publish"); - if (Directory.Exists(publishDir)) Directory.Delete(publishDir, true); + var imageTag = $"{repositoryUri}:latest"; - await RunProcess("dotnet", - $"publish -c Release -r linux-x64 --self-contained true -o \"{publishDir}\"", - testFunctionDir); + // Two flavors of `docker build`: + // - Host-publish (default, JIT functions): `dotnet publish` runs on the host and + // writes to bin/publish/, which the Dockerfile COPYs in. Build context = function dir. + // - Docker-publish (NativeAOT): the host can't cross-compile AOT for linux-x64 + // reliably (needs clang/zlib), so `dotnet publish` runs *inside* the image. + // The Dockerfile's project references reach back to Libraries\src\... and + // buildtools\common.props, so we stage those into a temp directory and use it + // as the build context. + string buildContextDir; + string dockerfilePath; + string? stagingDir = null; + + if (useDockerPublish) + { + stagingDir = StageBuildContextForDockerPublish(testFunctionDir); + buildContextDir = stagingDir; - var imageTag = $"{repositoryUri}:latest"; - await RunProcess("docker", - $"build --platform linux/amd64 --provenance=false -t {imageTag} .", - testFunctionDir); + // The function's project lives at the same relative path inside the staging dir. + var repoRoot = FindRepoRoot(testFunctionDir); + var relFunctionDir = Path.GetRelativePath(repoRoot, testFunctionDir); + dockerfilePath = Path.Combine(stagingDir, relFunctionDir, "Dockerfile"); + } + else + { + var publishDir = Path.Combine(testFunctionDir, "bin", "publish"); + if (Directory.Exists(publishDir)) Directory.Delete(publishDir, true); - var authResponse = await _ecrClient.GetAuthorizationTokenAsync(new GetAuthorizationTokenRequest()); - var authData = authResponse.AuthorizationData[0]; - var token = Encoding.UTF8.GetString(Convert.FromBase64String(authData.AuthorizationToken)); - var parts = token.Split(':'); - var registryUrl = authData.ProxyEndpoint; + await RunProcess("dotnet", + $"publish -c Release -r linux-x64 --self-contained true -o \"{publishDir}\"", + testFunctionDir); - await RunProcess("docker", - $"login --username {parts[0]} --password-stdin {registryUrl}", - testFunctionDir, - stdin: parts[1]); + buildContextDir = testFunctionDir; + dockerfilePath = Path.Combine(testFunctionDir, "Dockerfile"); + } - await RunProcess("docker", $"push {imageTag}", testFunctionDir); + try + { + await RunProcess("docker", + $"build --platform linux/amd64 --provenance=false -f \"{dockerfilePath}\" -t {imageTag} \"{buildContextDir}\"", + buildContextDir, + timeout: useDockerPublish ? TimeSpan.FromMinutes(20) : TimeSpan.FromMinutes(5)); + + var authResponse = await _ecrClient.GetAuthorizationTokenAsync(new GetAuthorizationTokenRequest()); + var authData = authResponse.AuthorizationData[0]; + var token = Encoding.UTF8.GetString(Convert.FromBase64String(authData.AuthorizationToken)); + var parts = token.Split(':'); + var registryUrl = authData.ProxyEndpoint; + + await RunProcess("docker", + $"login --username {parts[0]} --password-stdin {registryUrl}", + buildContextDir, + stdin: parts[1]); + + await RunProcess("docker", $"push {imageTag}", buildContextDir); + } + finally + { + if (stagingDir != null && Directory.Exists(stagingDir)) + { + try { Directory.Delete(stagingDir, true); } + catch (Exception ex) { _output.WriteLine($"Cleanup error (staging dir): {ex.Message}"); } + } + } return imageTag; } - private async Task RunProcess(string fileName, string arguments, string workingDir, string? stdin = null) + /// + /// Copies the minimum source tree needed to publish a NativeAOT durable function inside + /// a Docker container: buildtools/ (pulled in by Libraries\src\...\common.props), + /// Libraries/src/ (project references), and the function dir itself, all preserved + /// at their original relative paths so ProjectReferences resolve. + /// + private static string StageBuildContextForDockerPublish(string testFunctionDir) + { + var repoRoot = FindRepoRoot(testFunctionDir); + var stagingRoot = Path.Combine(Path.GetTempPath(), $"durable-aot-stage-{Guid.NewGuid():N}"); + Directory.CreateDirectory(stagingRoot); + + CopyDirectoryFiltered(Path.Combine(repoRoot, "buildtools"), Path.Combine(stagingRoot, "buildtools")); + CopyDirectoryFiltered(Path.Combine(repoRoot, "Libraries", "src"), Path.Combine(stagingRoot, "Libraries", "src")); + + var relFunctionDir = Path.GetRelativePath(repoRoot, testFunctionDir); + CopyDirectoryFiltered(testFunctionDir, Path.Combine(stagingRoot, relFunctionDir)); + + return stagingRoot; + } + + private static string FindRepoRoot(string startDir) + { + var dir = new DirectoryInfo(Path.GetFullPath(startDir)); + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "buildtools")) && + Directory.Exists(Path.Combine(dir.FullName, "Libraries"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new DirectoryNotFoundException( + $"Could not locate repo root (with buildtools/ and Libraries/) starting from {startDir}"); + } + + private static void CopyDirectoryFiltered(string source, string destination) + { + if (!Directory.Exists(source)) + throw new DirectoryNotFoundException($"Source directory not found: {source}"); + + Directory.CreateDirectory(destination); + foreach (var dirPath in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + // Skip build artifacts — they bloat the docker context and AOT publish needs a clean obj/. + var rel = Path.GetRelativePath(source, dirPath); + if (rel.Contains("bin", StringComparison.OrdinalIgnoreCase) || + rel.Contains("obj", StringComparison.OrdinalIgnoreCase) || + rel.Contains(".vs", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + Directory.CreateDirectory(Path.Combine(destination, rel)); + } + foreach (var filePath in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(source, filePath); + if (rel.Contains("bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + rel.Contains("obj" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + rel.Contains(".vs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + File.Copy(filePath, Path.Combine(destination, rel), overwrite: true); + } + } + + private async Task RunProcess(string fileName, string arguments, string workingDir, string? stdin = null, TimeSpan? timeout = null) { + var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5); _output.WriteLine($"Running: {fileName} {arguments}"); var psi = new System.Diagnostics.ProcessStartInfo { @@ -364,12 +475,12 @@ private async Task RunProcess(string fileName, string arguments, string workingD await Task.WhenAny( process.WaitForExitAsync(), - Task.Delay(TimeSpan.FromMinutes(5))); + Task.Delay(effectiveTimeout)); if (!process.HasExited) { process.Kill(); - throw new TimeoutException($"{fileName} timed out after 5 minutes"); + throw new TimeoutException($"{fileName} timed out after {effectiveTimeout.TotalMinutes} minutes"); } var stdout = await stdoutTask; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs index e73a6da7e..eadb1b7cf 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"started-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs index cc80e6afa..644ebabfa 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"a-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs index ce2a333b1..d4d0a4fde 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { // Step 1 generates a fresh GUID. On replay, this MUST return the cached value. var generatedId = await context.StepAsync( diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs index 9aeeed2a2..33c06c4e5 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { await context.StepAsync( async (_) => diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs index 5b6c291df..7bc16c663 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"validated-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile new file mode 100644 index 000000000..5c19cf43b --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile @@ -0,0 +1,42 @@ +# Multi-stage build: NativeAOT must be compiled on the target Linux toolchain. +# Build context is staged by the integration test (DurableFunctionDeployment.cs) +# and contains: buildtools/, Libraries/src/, and this function dir under +# Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/ +# so the project's relative ProjectReferences (..\..\..\..\src\...) resolve. + +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 AS build + +RUN dnf install -y \ + clang \ + zlib-devel \ + krb5-libs \ + openssl-libs \ + tar \ + gzip \ + libicu \ + && dnf clean all + +# Install the .NET 8, 9, and 10 SDKs. dotnet-install.sh works on AL2023 where +# `dnf install dotnet-sdk-*` may not be available. +# All three SDKs are required: the AOT project targets net8.0, but its ProjectReferences +# target net9.0 (RuntimeSupport, SnapshotRestore.Registry) and net10.0 (Core, +# DurableExecution, Serialization.SystemTextJson). Restore enumerates every TFM of +# every referenced project, so all SDKs must be present even when only net8.0 builds. +RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet \ + && /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \ + && /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \ + && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ + && rm /tmp/dotnet-install.sh + +WORKDIR /src +COPY . . + +WORKDIR /src/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction +RUN dotnet publish -c Release -r linux-x64 -o /publish + +FROM public.ecr.aws/lambda/provided:al2023 +RUN dnf install -y libicu && dnf clean all +COPY --from=build /publish/ ${LAMBDA_TASK_ROOT} +ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs new file mode 100644 index 000000000..0b7faf7aa --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace DurableExecutionAotTestFunction; + +public class Function +{ + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() + { + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } + + private static async Task Workflow(TestEvent input, IDurableContext context) + { + await context.WaitAsync(TimeSpan.FromSeconds(5), name: "only_wait"); + return new TestResult { Status = "completed", Data = "wait_only" }; + } +} + +public class TestEvent { public string? OrderId { get; set; } } +public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } + +[JsonSerializable(typeof(TestEvent))] +[JsonSerializable(typeof(TestResult))] +public partial class AotJsonContext : JsonSerializerContext +{ +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj new file mode 100644 index 000000000..f39bb0e7d --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + Exe + bootstrap + enable + enable + true + true + true + full + false + true + IL2026,IL2067,IL2075,IL3050 + + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs index 54e4ab737..d8ab6744c 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs @@ -1,4 +1,3 @@ -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,20 +6,17 @@ namespace DurableExecutionTestFunction; public class Function { - public static async Task Main(string[] args) + private static readonly DurableEntryPoint _entry = new(Workflow); + + public static async Task Main() { - var handler = new Function(); - var serializer = new DefaultLambdaJsonSerializer(); - using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); - using var bootstrap = new LambdaBootstrap(handlerWrapper); - await bootstrap.RunAsync(); + await LambdaBootstrapBuilder + .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); } - public Task Handler( - DurableExecutionInvocationInput input, ILambdaContext context) - => DurableFunction.WrapAsync(Workflow, input, context); - - private async Task Workflow(TestEvent input, IDurableContext context) + private static async Task Workflow(TestEvent input, IDurableContext context) { await context.WaitAsync(TimeSpan.FromSeconds(5), name: "only_wait"); return new TestResult { Status = "completed", Data = "wait_only" }; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs similarity index 77% rename from Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs rename to Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs index f30a302de..33c478f2b 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs @@ -1,6 +1,8 @@ +using System.IO; using System.Net; using System.Text.Json; using Amazon.Lambda; +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.DurableExecution.Internal; using Amazon.Lambda.Serialization.SystemTextJson; @@ -14,7 +16,14 @@ namespace Amazon.Lambda.DurableExecution.Tests; -public class DurableFunctionTests +/// +/// Drives through its public +/// Stream → Stream contract. Builds an internal envelope POCO, +/// serializes it via the library's internal envelope context (mirroring what the +/// real Lambda runtime delivers on the wire), then asserts on the deserialized +/// output envelope. InternalsVisibleTo lets us reach the internal types. +/// +public class DurableEntryPointTests { /// Reproduces the Id that emits for the n-th root-level operation. private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); @@ -26,8 +35,28 @@ private static TestLambdaContext CreateLambdaContext() => private readonly IAmazonLambda _mockClient = new MockLambdaClient(); + private static MemoryStream EnvelopeStream(DurableExecutionInvocationInput input) + { + var ms = new MemoryStream(); + JsonSerializer.Serialize(ms, input, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationInput); + ms.Position = 0; + return ms; + } + + private static async Task InvokeAsync( + Func> workflow, + DurableExecutionInvocationInput input, + ILambdaContext ctx, + IAmazonLambda lambdaClient) + { + var entry = new DurableEntryPoint(workflow, lambdaClient); + using var inStream = EnvelopeStream(input); + var outStream = await entry.InvokeAsync(inStream, ctx); + return JsonSerializer.Deserialize(outStream, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput)!; + } + [Fact] - public async Task WrapAsync_FreshExecution_StepThenWait_ReturnsPending() + public async Task FreshExecution_StepThenWait_ReturnsPending() { var input = new DurableExecutionInvocationInput { @@ -47,17 +76,14 @@ public async Task WrapAsync_FreshExecution_StepThenWait_ReturnsPending() } }; - var output = await DurableFunction.WrapAsync( - MyWorkflow, - input, - CreateLambdaContext(), - _mockClient); + var output = await InvokeAsync( + MyWorkflow, input, CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); } [Fact] - public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() + public async Task ReplayWithElapsedWait_ReturnsSucceeded() { var pastExpirationMs = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(); var input = new DurableExecutionInvocationInput @@ -92,11 +118,8 @@ public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() } }; - var output = await DurableFunction.WrapAsync( - MyWorkflow, - input, - CreateLambdaContext(), - _mockClient); + var output = await InvokeAsync( + MyWorkflow, input, CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.NotNull(output.Result); @@ -105,7 +128,7 @@ public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() } [Fact] - public async Task WrapAsync_WorkflowThrows_ReturnsFailed() + public async Task WorkflowThrows_ReturnsFailed() { var input = new DurableExecutionInvocationInput { @@ -125,11 +148,9 @@ public async Task WrapAsync_WorkflowThrows_ReturnsFailed() } }; - var output = await DurableFunction.WrapAsync( + var output = await InvokeAsync( async (evt, ctx) => throw new InvalidOperationException("workflow error"), - input, - CreateLambdaContext(), - _mockClient); + input, CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); Assert.NotNull(output.Error); @@ -138,7 +159,7 @@ public async Task WrapAsync_WorkflowThrows_ReturnsFailed() } [Fact] - public async Task WrapAsync_VoidWorkflow_ReturnSucceeded() + public async Task VoidWorkflow_ReturnsSucceeded() { var input = new DurableExecutionInvocationInput { @@ -159,21 +180,23 @@ public async Task WrapAsync_VoidWorkflow_ReturnSucceeded() }; var executed = false; - var output = await DurableFunction.WrapAsync( + var entry = new DurableEntryPoint( async (evt, ctx) => { await ctx.StepAsync(async (_) => { await Task.CompletedTask; executed = true; }, name: "do_work"); }, - input, - CreateLambdaContext(), _mockClient); + using var inStream = EnvelopeStream(input); + var outStream = await entry.InvokeAsync(inStream, CreateLambdaContext()); + var output = JsonSerializer.Deserialize(outStream, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput)!; + Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.True(executed); } [Fact] - public async Task WrapAsync_CheckpointsAreSentToService() + public async Task CheckpointsAreSentToService() { var mockClient = new MockLambdaClient(); var input = new DurableExecutionInvocationInput @@ -195,11 +218,8 @@ public async Task WrapAsync_CheckpointsAreSentToService() } }; - var output = await DurableFunction.WrapAsync( - MyWorkflow, - input, - CreateLambdaContext(), - mockClient); + var output = await InvokeAsync( + MyWorkflow, input, CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); Assert.Equal(2, mockClient.CheckpointCalls.Count); @@ -229,10 +249,10 @@ public async Task WrapAsync_CheckpointsAreSentToService() } [Fact] - public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() + public async Task UserPayload_BindsCamelCaseToPascalCaseProperty() { // The wire payload uses camelCase ("orderId"), the user POCO uses PascalCase (OrderId). - // ExtractUserPayload must do case-insensitive binding so workflows can read input.OrderId. + // Stage-2 deserialization must do case-insensitive binding so workflows can read input.OrderId. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:case-test", @@ -252,27 +272,25 @@ public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() }; string? observedOrderId = null; - var output = await DurableFunction.WrapAsync( + var output = await InvokeAsync( async (evt, ctx) => { observedOrderId = evt.OrderId; await Task.CompletedTask; return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, - input, - CreateLambdaContext(), - _mockClient); + input, CreateLambdaContext(), _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.Equal("abc-123", observedOrderId); } [Fact] - public async Task WrapAsync_NoExecutionOp_ThrowsMalformedEnvelope() + public async Task NoExecutionOp_ThrowsMalformedEnvelope() { - // No EXECUTION operation in the envelope — ExtractUserPayload must throw a typed - // DurableExecutionException so the malformed envelope surfaces as a clear error - // instead of leaking default!/null into user code as a NullReferenceException. + // No EXECUTION operation in the envelope — DurableEntryPointCore.ExtractUserPayload must + // throw a typed DurableExecutionException so the malformed envelope surfaces as a clear + // error instead of leaking default!/null into user code as a NullReferenceException. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:no-exec", @@ -282,29 +300,26 @@ public async Task WrapAsync_NoExecutionOp_ThrowsMalformedEnvelope() } }; - var ex = await Assert.ThrowsAsync(() => - DurableFunction.WrapAsync( - async (evt, ctx) => - { - await Task.CompletedTask; - return new OrderResult { Status = "ok" }; - }, - input, - CreateLambdaContext(), - _mockClient)); + var entry = new DurableEntryPoint( + async (evt, ctx) => { await Task.CompletedTask; return new OrderResult { Status = "ok" }; }, + _mockClient); + + using var inStream = EnvelopeStream(input); + var ex = await Assert.ThrowsAsync( + () => entry.InvokeAsync(inStream, CreateLambdaContext())); Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("EXECUTION", ex.Message); } [Fact] - public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() + public async Task PaginatedInitialState_HydratesAllPages() { // The service can return execution state across multiple pages — the first // page comes inline on the invocation envelope (InitialExecutionState) and // subsequent pages must be fetched via GetDurableExecutionState. Verify the - // pagination loop in WrapAsyncCore (DurableFunction.cs:160-167) walks every - // page so the workflow sees the full operation history on replay. + // pagination loop in DurableEntryPointCore walks every page so the workflow + // sees the full operation history on replay. var arn = "arn:aws:lambda:us-east-1:123:durable-execution:paginated"; // Page 0 (in InitialExecutionState): EXECUTION op + step1 SUCCEEDED. @@ -374,7 +389,7 @@ public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() }; var observed = new List(); - var output = await DurableFunction.WrapAsync( + var output = await InvokeAsync( async (evt, ctx) => { // All three steps must replay the cached results from the paginated state @@ -388,9 +403,7 @@ public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() async (_) => { await Task.CompletedTask; return "fresh"; }, name: "step3")); return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, - input, - CreateLambdaContext(), - mockClient); + input, CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -409,7 +422,7 @@ public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() } [Fact] - public async Task WrapAsync_NullInitialExecutionState_ThrowsMalformedEnvelope() + public async Task NullInitialExecutionState_ThrowsMalformedEnvelope() { // No initial execution state at all — same malformed-envelope branch in ExtractUserPayload. var input = new DurableExecutionInvocationInput @@ -417,20 +430,52 @@ public async Task WrapAsync_NullInitialExecutionState_ThrowsMalformedEnvelope() DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:null-state" }; - var ex = await Assert.ThrowsAsync(() => - DurableFunction.WrapAsync( - async (evt, ctx) => - { - await Task.CompletedTask; - return new OrderResult { Status = "ok" }; - }, - input, - CreateLambdaContext(), - _mockClient)); + var entry = new DurableEntryPoint( + async (evt, ctx) => { await Task.CompletedTask; return new OrderResult { Status = "ok" }; }, + _mockClient); + + using var inStream = EnvelopeStream(input); + var ex = await Assert.ThrowsAsync( + () => entry.InvokeAsync(inStream, CreateLambdaContext())); Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task NoSerializerOnContext_ThrowsHelpfulException() + { + // Stage 2 (user payload (de)serialization) requires a serializer registered on + // ILambdaContext.Serializer. Without one, the entry point should throw a + // self-explanatory InvalidOperationException rather than NRE on the next line. + var input = new DurableExecutionInvocationInput + { + DurableExecutionArn = "arn:test", + InitialExecutionState = new InitialExecutionState + { + Operations = new List + { + new() + { + Id = "exec-0", + Type = OperationTypes.Execution, + Status = OperationStatuses.Started, + ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"x\"}" } + } + } + } + }; + + var entry = new DurableEntryPoint( + async (evt, ctx) => { await Task.CompletedTask; return new OrderResult(); }, + _mockClient); + + using var inStream = EnvelopeStream(input); + var ex = await Assert.ThrowsAsync( + () => entry.InvokeAsync(inStream, new TestLambdaContext())); + + Assert.Contains("ILambdaContext.Serializer", ex.Message); + } + // ────────────────────────────────────────────────────────────────────── // IsTerminalCheckpointError classification (mirrors CheckpointError in // aws-durable-execution-sdk-python): @@ -439,7 +484,7 @@ public async Task WrapAsync_NullInitialExecutionState_ThrowsMalformedEnvelope() // Carve-out: InvalidParameterValueException "Invalid Checkpoint Token" → transient // // Driven through CheckpointDurableExecution: a workflow that succeeds a single Step - // forces the batcher to flush, which is wrapped by the try/catch in WrapAsyncCore. + // forces the batcher to flush, which is wrapped by the try/catch in DurableEntryPointCore. // ────────────────────────────────────────────────────────────────────── public static IEnumerable TerminalCheckpointErrorCases() => new[] @@ -453,15 +498,12 @@ public static IEnumerable TerminalCheckpointErrorCases() => new[] [Theory] [MemberData(nameof(TerminalCheckpointErrorCases))] - public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) + public async Task CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) { - // LambdaDurableServiceClient now wraps SDK exceptions in DurableExecutionException - // so user logs carry context (which call, which ARN). The outer message includes - // the inner SDK message; the classifier matches on the wrapper's InnerException. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; - var output = await DurableFunction.WrapAsync( + var output = await InvokeAsync( SingleStepWorkflow, input, CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); @@ -485,25 +527,21 @@ public static IEnumerable TransientCheckpointErrorCases() => new[] [Theory] [MemberData(nameof(TransientCheckpointErrorCases))] - public async Task WrapAsync_CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) + public async Task CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) { - // Transient SDK errors escape the IsTerminalCheckpointError catch and propagate - // to the host as DurableExecutionException wrapping the original SDK exception - // — Lambda's normal retry semantics fire on the wrapper. The original SDK - // exception is preserved as InnerException so callers can still introspect - // the original status code / error code. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; + var entry = new DurableEntryPoint(SingleStepWorkflow, mockClient); - var thrown = await Assert.ThrowsAsync(() => - DurableFunction.WrapAsync( - SingleStepWorkflow, input, CreateLambdaContext(), mockClient)); + using var inStream = EnvelopeStream(input); + var thrown = await Assert.ThrowsAsync( + () => entry.InvokeAsync(inStream, CreateLambdaContext())); Assert.Same(ex, thrown.InnerException); } [Fact] - public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() + public async Task HydrationThrows_AlwaysPropagatesToHost() { // State hydration is OUTSIDE the IsTerminalCheckpointError try/catch — every // GetExecutionStateAsync failure escapes for Lambda retry, matching Python's @@ -529,13 +567,11 @@ public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() }; var ex = MakeServiceException("ResourceNotFoundException", HttpStatusCode.NotFound, "ARN gone"); var mockClient = new MockLambdaClient { GetExecutionStateThrows = ex }; + var entry = new DurableEntryPoint(MyWorkflow, mockClient); - // Hydration errors are wrapped in DurableExecutionException by - // LambdaDurableServiceClient.GetExecutionStateAsync but are NOT caught by the - // IsTerminalCheckpointError filter, so they escape to the host. - var thrown = await Assert.ThrowsAsync(() => - DurableFunction.WrapAsync( - MyWorkflow, input, CreateLambdaContext(), mockClient)); + using var inStream = EnvelopeStream(input); + var thrown = await Assert.ThrowsAsync( + () => entry.InvokeAsync(inStream, CreateLambdaContext())); Assert.Same(ex, thrown.InnerException); Assert.Contains("Failed to fetch execution state", thrown.Message); From 00a8da9e9571ee938b586dd68459bc3b35ace9e5 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 16:29:47 -0400 Subject: [PATCH 12/16] Revert "serialization updates" This reverts commit 3a596371e4662b5b0b764579bfc9c111bbd4248f. --- Docs/durable-execution-design.md | 276 +++++++++++------- .../DurableContext.cs | 6 +- .../DurableEntryPoint.cs | 112 ------- .../DurableExecutionInvocationInput.cs | 17 +- .../DurableExecutionInvocationOutput.cs | 7 +- ...leEntryPointCore.cs => DurableFunction.cs} | 90 +++++- .../Amazon.Lambda.DurableExecution/Enums.cs | 5 +- .../ErrorObject.cs | 5 +- .../IDurableContext.cs | 8 +- .../Internal/DurableEnvelopeJsonContext.cs | 15 - .../Internal/InvocationStatusConverter.cs | 8 - .../Internal/UpperSnakeCaseEnumConverter.cs | 2 +- .../Program.cs | 24 +- .../AotWaitOnlyTest.cs | 56 ---- .../DurableFunctionDeployment.cs | 165 ++--------- .../LongerWaitFunction/Function.cs | 20 +- .../MultipleStepsFunction/Function.cs | 20 +- .../ReplayDeterminismFunction/Function.cs | 20 +- .../StepFailsFunction/Function.cs | 20 +- .../StepWaitStepFunction/Function.cs | 20 +- .../WaitOnlyAotFunction/Dockerfile | 42 --- .../WaitOnlyAotFunction/Function.cs | 34 --- .../WaitOnlyAotFunction.csproj | 25 -- .../WaitOnlyFunction/Function.cs | 20 +- ...yPointTests.cs => DurableFunctionTests.cs} | 212 ++++++-------- 25 files changed, 476 insertions(+), 753 deletions(-) delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs rename Libraries/src/Amazon.Lambda.DurableExecution/{Internal/DurableEntryPointCore.cs => DurableFunction.cs} (64%) delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs delete mode 100644 Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj rename Libraries/test/Amazon.Lambda.DurableExecution.Tests/{DurableEntryPointTests.cs => DurableFunctionTests.cs} (77%) diff --git a/Docs/durable-execution-design.md b/Docs/durable-execution-design.md index 64f903e02..6df424c5f 100644 --- a/Docs/durable-execution-design.md +++ b/Docs/durable-execution-design.md @@ -79,13 +79,11 @@ Your function reads like a normal async method. The SDK deals with state, replay Durable functions use a replay-based execution model. Every invocation runs your code from the top, but previously completed steps return their cached result instead of re-executing. -1. Lambda invokes your function with a service-envelope payload containing: +1. Lambda invokes your function with a `DurableExecutionInvocationInput` containing: - `DurableExecutionArn` -- unique execution identifier - `CheckpointToken` -- for optimistic concurrency - `InitialExecutionState` -- previously checkpointed operations - The SDK reads this envelope, hands your workflow only the user payload, and writes the response envelope on the way out — your code never sees the wire format. - 2. Your function code runs **from the beginning** on every invocation. 3. When a **step** is encountered: @@ -192,28 +190,23 @@ Things to notice: #### Manual Handler (Without Annotations) -If you don't use `Amazon.Lambda.Annotations`, register `DurableEntryPoint` as your Lambda handler. The entry point owns all wire-envelope (de)serialization — your workflow only deals with `TInput`/`TOutput`: +If you don't use `Amazon.Lambda.Annotations`, use `DurableFunction.WrapAsync` — a static helper (inspired by [OpenTelemetry's `AWSLambdaWrapper.TraceAsync`](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/tree/main/src/OpenTelemetry.Instrumentation.AWSLambda#lambda-function)) that handles the entire durable execution envelope for you: ```csharp +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; -using Amazon.Lambda.RuntimeSupport; -using Amazon.Lambda.Serialization.SystemTextJson; + +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] namespace MyDurableFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(MyWorkflow); - - public static async Task Main() - { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); - } + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); - private static async Task MyWorkflow(OrderEvent input, IDurableContext context) + private async Task MyWorkflow(OrderEvent input, IDurableContext context) { var validation = await context.StepAsync( async (step) => await ValidateOrder(input.OrderId), @@ -231,23 +224,26 @@ public class Function return new OrderResult { Status = "approved", OrderId = result.OrderId }; } - private static async Task ValidateOrder(string orderId) { /* ... */ } - private static async Task ProcessOrder(string orderId) { /* ... */ } + private async Task ValidateOrder(string orderId) { /* ... */ } + private async Task ProcessOrder(string orderId) { /* ... */ } } ``` -`DurableEntryPoint.InvokeAsync` is a `Stream → Stream` Lambda handler. It: -- Deserializes the service envelope using a library-internal `JsonSerializerContext` -- Hydrates `ExecutionState` from `InitialExecutionState` -- Extracts the user payload via the registered `ILambdaSerializer` and runs your workflow through `DurableExecutionHandler.RunAsync` -- Serializes the result using the registered `ILambdaSerializer`, wraps it in the response envelope, and writes the envelope back to the stream +`DurableFunction.WrapAsync` handles all the plumbing: +- Hydrates `ExecutionState` from `invocationInput.InitialExecutionState` +- Extracts the user payload from the service envelope +- Runs the workflow through `DurableExecutionHandler.RunAsync` +- Constructs and returns the `DurableExecutionInvocationOutput` envelope (status mapping, JSON serialization) +- Sets execution environment tracking -For workflows that return no value, use the single-type-parameter form: +For workflows that return no value, use the single-type-parameter overload: ```csharp -private static readonly DurableEntryPoint _entry = new(MyWorkflow); +public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); -private static async Task MyWorkflow(OrderEvent input, IDurableContext context) +private async Task MyWorkflow(OrderEvent input, IDurableContext context) { await context.StepAsync(async (step) => await SendNotification(input.UserId), name: "notify"); await context.WaitAsync(TimeSpan.FromHours(1), name: "cooldown"); @@ -255,37 +251,41 @@ private static async Task MyWorkflow(OrderEvent input, IDurableContext context) } ``` -For **NativeAOT** deployments, register a `SourceGeneratorLambdaJsonSerializer` whose `JsonSerializerContext` lists only your own types. The library's internal envelope context handles the wire format — users never register envelope types, so no source-gen warnings or accessibility errors: +For **NativeAOT** deployments, pass a `JsonSerializerContext` so the SDK can serialize/deserialize your input and output types without reflection: ```csharp [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] -public partial class MyJsonContext : JsonSerializerContext { } +internal partial class MyJsonContext : JsonSerializerContext { } public class Function { - private static readonly DurableEntryPoint _entry = new(MyWorkflow); - - public static async Task Main() - { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) - .Build() - .RunAsync(); - } + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, MyJsonContext.Default); - private static async Task MyWorkflow(OrderEvent input, IDurableContext context) + private async Task MyWorkflow(OrderEvent input, IDurableContext context) { // ... } } ``` -To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), pass it to the `DurableEntryPoint` constructor: +To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), use the overload that accepts one: ```csharp -private static readonly DurableEntryPoint _entry = - new(MyWorkflow, new AmazonLambdaClient(/* custom config */)); +public class Function +{ + private readonly IAmazonLambda _lambdaClient; + + public Function(IAmazonLambda lambdaClient) => _lambdaClient = lambdaClient; + + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, _lambdaClient); +} ``` You'd also need to manually configure the CloudFormation template with `DurableConfig` and managed policies: @@ -296,7 +296,7 @@ You'd also need to manually configure the CloudFormation template with `DurableC "MyFunction": { "Type": "AWS::Serverless::Function", "Properties": { - "Handler": "MyDurableFunction", + "Handler": "MyDurableFunction::MyDurableFunction.Function::FunctionHandler", "Policies": [ "AWSLambdaBasicExecutionRole", "AWSLambdaBasicDurableExecutionRolePolicy" @@ -310,23 +310,46 @@ You'd also need to manually configure the CloudFormation template with `DurableC } ``` -##### Two-stage (de)serialization +##### What WrapAsync does internally -The reason `DurableEntryPoint` exists — instead of a typed `(EnvelopeIn, ILambdaContext) → EnvelopeOut` handler — is to keep the wire envelope and its internal types out of the user's `JsonSerializerContext`. Under AOT/source-gen JSON, the user's context lives in a different assembly than the library, so it can't see internal envelope types or attribute-referenced internal converters. Splitting (de)serialization into two stages avoids the leak entirely: +For reference, here's the expanded version of what `DurableFunction.WrapAsync` eliminates — this is effectively what the source generator produces for the Annotations path: -| Stage | Owner | Reads/writes | Context used | -|---|---|---|---| -| 1. Envelope | Library | `Stream` ↔ `DurableExecutionInvocationInput`/`Output` | Internal `DurableEnvelopeJsonContext` (sees all internal types) | -| 2. User payload | User | `string` ↔ `TInput`/`TOutput` | The `ILambdaSerializer` you register with `LambdaBootstrapBuilder` | +```csharp +public async Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) +{ + // 1. Hydrate execution state from previously checkpointed operations + var state = new ExecutionState(); + state.LoadFromCheckpoint(invocationInput.InitialExecutionState); -The user's serializer is read from `ILambdaContext.Serializer` at invocation time (the new `LambdaBootstrapBuilder.Create(Func>, ILambdaSerializer)` overload propagates it). With AOT this means the user only registers their own POCOs; the library's envelope types stay internal. + // 2. Extract user payload from the service envelope (internal) + var userPayload = ExtractUserPayload(invocationInput); + + // 3. Run the user's workflow via DurableExecutionHandler.RunAsync + var result = await DurableExecutionHandler.RunAsync( + state, + async (durableContext) => await MyWorkflow(userPayload, durableContext), + invocationInput.DurableExecutionArn); + + // 4. Construct and return the service output envelope + return new DurableExecutionInvocationOutput + { + Status = result.Status, + Result = result.Status == InvocationStatus.Succeeded + ? JsonSerializer.Serialize(result.Result) + : null, + ErrorMessage = result.Message + }; +} +``` -Differences vs the Annotations approach: -- You define `Main` and call `LambdaBootstrapBuilder` yourself +Key differences between `WrapAsync` and the Annotations approach: +- `WrapAsync` still requires you to define the Lambda entry point signature (`DurableExecutionInvocationInput` → `DurableExecutionInvocationOutput`) - You configure `DurableConfig` + managed policies in your CloudFormation template manually (not generated) - No `[LambdaFunction]` or `[DurableExecution]` attributes needed -With `[LambdaFunction] + [DurableExecution]`, even the `Main` entry point and CloudFormation config are generated at compile time — you just write the workflow method. +With `[LambdaFunction] + [DurableExecution]`, even the entry point and CloudFormation config are generated at compile time — you just write the workflow method. --- @@ -909,49 +932,109 @@ When user code hits a pending wait or callback: ## API Reference -### DurableEntryPoint +### DurableFunction -The non-Annotations Lambda handler. Reads the wire envelope from a `Stream`, runs the workflow, and writes the response envelope back. Same shape regardless of JIT or AOT — the only thing that varies is the `ILambdaSerializer` you register with `LambdaBootstrapBuilder`. +Static helper for the non-Annotations handler path. Wraps a workflow function, handling all envelope translation between `DurableExecutionInvocationInput`/`DurableExecutionInvocationOutput` and user types. ```csharp /// -/// AOT-friendly entry point for a durable workflow. Owns (de)serialization of -/// the wire envelope so users only register their own POCO types in their -/// JsonSerializerContext. +/// Static helper that wraps a durable workflow function, handling all envelope +/// translation between DurableExecutionInvocationInput/Output and user types. +/// Inspired by OpenTelemetry.Instrumentation.AWSLambda's AWSLambdaWrapper.TraceAsync pattern. /// -public sealed class DurableEntryPoint +public static class DurableFunction { + // ── Reflection-based overloads (JIT only) ────────────────────────── + /// - /// Uses a default AmazonLambdaClient, constructed lazily and cached process-wide. + /// Wrap a workflow that takes typed input and returns typed output. + /// Reflection-based JSON — not AOT-safe. /// - public DurableEntryPoint(Func> workflow); + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext); /// - /// Uses the supplied IAmazonLambda for checkpoint and state-fetch calls. + /// Wrap a workflow (typed input + output) with explicit Lambda client. + /// Reflection-based JSON — not AOT-safe. /// - public DurableEntryPoint(Func> workflow, IAmazonLambda lambdaClient); + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient); /// - /// Lambda handler entry point. Register with LambdaBootstrapBuilder alongside - /// an ILambdaSerializer that knows how to (de)serialize TInput/TOutput. + /// Wrap a void workflow (typed input, no output). + /// Reflection-based JSON — not AOT-safe. /// - public Task InvokeAsync(Stream input, ILambdaContext context); -} + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext); -/// -/// AOT-friendly entry point for a void durable workflow. -/// -public sealed class DurableEntryPoint -{ - public DurableEntryPoint(Func workflow); - public DurableEntryPoint(Func workflow, IAmazonLambda lambdaClient); - public Task InvokeAsync(Stream input, ILambdaContext context); -} -``` + /// + /// Wrap a void workflow with explicit Lambda client. + /// Reflection-based JSON — not AOT-safe. + /// + [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient); -`DurableEntryPoint.InvokeAsync` requires an `ILambdaSerializer` to be registered on `ILambdaContext.Serializer`; if it's null, the entry point throws `InvalidOperationException` with a message pointing to `LambdaBootstrapBuilder.Create(handler, serializer)`. In tests, set `TestLambdaContext.Serializer` directly. + // ── AOT-safe overloads (caller supplies JsonSerializerContext) ────── -The wire-envelope types (`DurableExecutionInvocationInput`/`Output`, `InvocationStatus`, `ErrorObject`) are intentionally `internal` — user code never constructs or reads them. + /// + /// Wrap a workflow (typed input + output). AOT-safe — requires + /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] + /// on the supplied jsonContext. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext); + + /// + /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext); + + /// + /// Wrap a void workflow (typed input, no output). AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + JsonSerializerContext jsonContext); + + /// + /// Wrap a void workflow with explicit Lambda client. AOT-safe. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient, + JsonSerializerContext jsonContext); +} +``` ### IDurableContext @@ -1630,11 +1713,11 @@ Both approaches produce a self-contained executable that the Lambda custom runti ### NativeAOT compatibility -The SDK is AOT-friendly but does not require AOT. AOT safety is addressed at two levels: +The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. For NativeAOT deployments, AOT safety is addressed at two levels — **at each level there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` and an AOT-safe one that requires a serializer parameter**. The trimmer warns at the call site when reflection overloads are used in AOT/trimmed builds. -1. **Entry point** — `DurableEntryPoint` owns wire-envelope (de)serialization through an internal `JsonSerializerContext`. The user-supplied `ILambdaSerializer` (registered with `LambdaBootstrapBuilder`) only handles `TInput`/`TOutput`. For AOT, register a `SourceGeneratorLambdaJsonSerializer` whose context lists only your own POCOs — envelope types stay private to the library and never need to appear in the user's context. +1. **Entry point (`DurableFunction.WrapAsync`)** — the AOT-safe overload takes a `JsonSerializerContext` parameter that includes type info for your `TInput` and `TOutput` types. -2. **Step checkpoints (`IDurableContext.StepAsync`)** — there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]`, and an AOT-safe one that takes an `ICheckpointSerializer` parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. +2. **Step checkpoints (`IDurableContext.StepAsync`)** — the AOT-safe overload takes an `ICheckpointSerializer` directly as a parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. @@ -1642,22 +1725,16 @@ The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other re // Default: works with reflection (JIT mode); flagged for AOT. var result = await context.StepAsync(async (step) => await GetOrder()); -// AOT mode — entry point: register a source-generated ILambdaSerializer. -// Only your own types appear in the context — no envelope types. +// AOT mode — entry point: pass JsonSerializerContext to WrapAsync. [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] [JsonSerializable(typeof(Order))] -public partial class MyJsonContext : JsonSerializerContext { } +internal partial class MyJsonContext : JsonSerializerContext { } -private static readonly DurableEntryPoint _entry = new(MyWorkflow); - -public static async Task Main() -{ - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) - .Build() - .RunAsync(); -} +public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync( + MyWorkflow, invocationInput, context, MyJsonContext.Default); // AOT mode — step checkpoint: pass ICheckpointSerializer to StepAsync directly. var result = await context.StepAsync( @@ -1679,7 +1756,7 @@ The SDK handles overflow transparently: **Batch results (map/parallel) exceeding limits:** For large map/parallel operations, the SDK generates a compact summary for the parent operation's checkpoint. The summary includes item count, success/failure counts, and completion reason — but not individual item results. During replay, the SDK sets `ReplayChildren = true` on the state request, which causes the service to return child operation records so full results can be reconstructed. -**Lambda response exceeding 6 MB:** If the final orchestration result exceeds the response payload limit, the SDK checkpoints the result before returning the response envelope. The service reads the result from the checkpoint rather than from the response body. +**Lambda response exceeding 6 MB:** If the final orchestration result exceeds the response payload limit, the SDK checkpoints the result before returning the `DurableExecutionInvocationOutput`. The service reads the result from the checkpoint rather than from the response body. **Guidance for very large results:** For results that are inherently large (multi-MB payloads), use a custom `ICheckpointSerializer` that offloads to external storage (S3, DynamoDB) and returns a reference. This keeps checkpoint sizes small and avoids pagination overhead: @@ -1718,10 +1795,9 @@ The SDK uses existing Lambda core interfaces: The durable execution handler integrates with the existing runtime support bootstrap: ```csharp -// The [DurableExecution] attribute signals that the handler is a durable workflow. -// The Annotations source generator emits a Main method that registers a -// DurableEntryPoint with LambdaBootstrapBuilder. The wire envelope -// is invisible to the user's handler — they receive TInput and return TOutput. +// The [DurableExecution] attribute signals that the handler +// receives DurableExecutionInvocationInput and returns DurableExecutionInvocationOutput +// The SDK handles the translation to/from the user's handler signature ``` ### Amazon.Lambda.Annotations (optional) @@ -1730,7 +1806,7 @@ The durable execution handler integrates with the existing runtime support boots When both packages are referenced, the Annotations source generator detects `[DurableExecution]` by fully-qualified name and at compile time: -1. Generates a `Main` entry point that wires up `DurableEntryPoint` for your workflow +1. Generates a handler wrapper that translates `DurableExecutionInvocationInput` to/from your types 2. Manages context lifecycle (creation, checkpoint batching, cleanup) 3. Adds `DurableConfig` to the CloudFormation template 4. Adds the `AWSLambdaBasicDurableExecutionRolePolicy` managed policy @@ -1777,7 +1853,7 @@ public class Functions } ``` -When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path, pass the client to the `DurableEntryPoint` constructor. +When no `LambdaClientFactory` is specified, the generated code creates a default `AmazonLambdaClient`. For the manual handler path (`DurableFunction.WrapAsync`), pass the client directly via the `IAmazonLambda lambdaClient` overload. > **Dependency boundaries:** `Amazon.Lambda.Annotations` has **no dependency** on the AWS SDK or on `Amazon.Lambda.DurableExecution`. The Annotations source generator references durable execution types by fully-qualified name strings only — it never takes a compile-time dependency on the durable package. The `[DurableExecution]` attribute is defined in `Amazon.Lambda.DurableExecution`, and the generated code resolves against the user's project references. There is only one source generator (Annotations) — no coordination between multiple generators is needed. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs index e79cee30b..e01a26604 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -69,10 +69,8 @@ private Task RunStep( var serializer = LambdaContext.Serializer ?? throw new InvalidOperationException( "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + - "In the class library programming model, register one with " + - "[assembly: LambdaSerializer(typeof(...))]. In an executable / custom " + - "runtime, pass it to LambdaBootstrapBuilder.Create(handler, serializer). " + - "In tests, set TestLambdaContext.Serializer."); + "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + + "(or in tests, set TestLambdaContext.Serializer)."); var operationId = _idGenerator.NextId(); var op = new StepOperation( diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs deleted file mode 100644 index 1fbff7e8b..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableEntryPoint.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using Amazon.Lambda; -using Amazon.Lambda.Core; -using Amazon.Lambda.DurableExecution.Internal; -using Amazon.Lambda.DurableExecution.Services; -using Amazon.Lambda.Model; -using Amazon.Runtime; - -namespace Amazon.Lambda.DurableExecution; - -/// -/// AOT-friendly entry point for a durable workflow. Owns (de)serialization of -/// the wire envelope so users only register their own POCO types in their -/// JsonSerializerContext — the library's -/// handles envelope JSON, the user's (read from -/// ) handles only -/// and . -/// -/// The workflow's input payload type. -/// The workflow's return type. -/// -/// -/// private static readonly DurableEntryPoint<OrderEvent, OrderResult> _entry = new(MyWorkflow); -/// -/// static async Task Main() -/// { -/// await LambdaBootstrapBuilder -/// .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer<MyJsonContext>()) -/// .Build() -/// .RunAsync(); -/// } -/// -/// -public sealed class DurableEntryPoint -{ - private static readonly Lazy _cachedLambdaClient = - new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); - - private readonly Func> _workflow; - private readonly IAmazonLambda _lambdaClient; - - /// - /// Creates an entry point that uses a default , - /// constructed lazily and cached process-wide. - /// - public DurableEntryPoint(Func> workflow) - : this(workflow, _cachedLambdaClient.Value) - { - } - - /// - /// Creates an entry point that uses the supplied client - /// for checkpoint and state-fetch calls. - /// - public DurableEntryPoint(Func> workflow, IAmazonLambda lambdaClient) - { - _workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); - _lambdaClient = lambdaClient ?? throw new ArgumentNullException(nameof(lambdaClient)); - } - - /// - /// Lambda handler entry point. Register this method with LambdaBootstrapBuilder - /// alongside an that knows how to (de)serialize - /// / . - /// - public async Task InvokeAsync(Stream input, ILambdaContext context) - { - var output = await DurableEntryPointCore.InvokeAsync(_workflow, input, context, _lambdaClient); - var ms = new MemoryStream(); - JsonSerializer.Serialize(ms, output, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput); - ms.Position = 0; - return ms; - } -} - -/// -/// AOT-friendly entry point for a void durable workflow. -/// See for details. -/// -public sealed class DurableEntryPoint -{ - private readonly DurableEntryPoint _inner; - - /// - /// Creates an entry point that uses a default , - /// constructed lazily and cached process-wide. - /// - public DurableEntryPoint(Func workflow) - { - if (workflow == null) throw new ArgumentNullException(nameof(workflow)); - _inner = new DurableEntryPoint(async (i, c) => { await workflow(i, c); return null; }); - } - - /// - /// Creates an entry point that uses the supplied client - /// for checkpoint and state-fetch calls. - /// - public DurableEntryPoint(Func workflow, IAmazonLambda lambdaClient) - { - if (workflow == null) throw new ArgumentNullException(nameof(workflow)); - _inner = new DurableEntryPoint( - async (i, c) => { await workflow(i, c); return null; }, - lambdaClient); - } - - /// - public Task InvokeAsync(Stream input, ILambdaContext context) - => _inner.InvokeAsync(input, context); -} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs index 6d1dc7acf..35bc32ecd 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs @@ -5,10 +5,9 @@ namespace Amazon.Lambda.DurableExecution; /// /// The service envelope input for a durable execution invocation. -/// owns (de)serialization -/// end-to-end so users only register their own POCO types. +/// This is what Lambda receives from the durable execution service. /// -internal sealed class DurableExecutionInvocationInput +public sealed class DurableExecutionInvocationInput { /// /// The unique ARN identifying this durable execution. @@ -23,13 +22,15 @@ internal sealed class DurableExecutionInvocationInput public string? CheckpointToken { get; set; } /// - /// Previously checkpointed operation state for replay. Declared public - /// so emits a setter — STJ - /// source-gen reads declared accessibility, not effective accessibility, and - /// silently skips internal-declared members even within the same assembly. + /// Previously checkpointed operation state for replay. Internal — consumed + /// only by DurableFunction.WrapAsync for replay correlation; user code + /// should never read or modify this. Marked + /// so System.Text.Json populates it during deserialization despite being internal + /// (framework needs it, but it's not part of the public API contract). /// [JsonPropertyName("InitialExecutionState")] - public InitialExecutionState? InitialExecutionState { get; set; } + [JsonInclude] + internal InitialExecutionState? InitialExecutionState { get; set; } } /// diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs index 3527cc16c..0e187e015 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using Amazon.Lambda.DurableExecution.Internal; @@ -5,16 +6,14 @@ namespace Amazon.Lambda.DurableExecution; /// /// The service envelope output returned by a durable execution invocation. -/// Written by directly to the -/// Lambda response stream. /// -internal sealed class DurableExecutionInvocationOutput +public sealed class DurableExecutionInvocationOutput { /// /// The terminal status of this invocation. /// [JsonPropertyName("Status")] - [JsonConverter(typeof(InvocationStatusConverter))] + [JsonConverter(typeof(UpperSnakeCaseEnumConverter))] public required InvocationStatus Status { get; set; } /// diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs similarity index 64% rename from Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index 3e341146b..f1394c5bf 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEntryPointCore.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -1,37 +1,82 @@ using System.IO; using System.Text; -using System.Text.Json; +using System.Threading; using Amazon.Lambda; using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution.Internal; using Amazon.Lambda.DurableExecution.Services; using Amazon.Lambda.Model; using Amazon.Runtime; -namespace Amazon.Lambda.DurableExecution.Internal; +namespace Amazon.Lambda.DurableExecution; /// -/// Shared orchestration body for . -/// Reads the envelope with the library context, runs the workflow with the user's -/// serializer for TInput/TOutput, returns the populated output envelope. +/// Static helper that wraps a durable workflow function, handling all envelope +/// translation between DurableExecutionInvocationInput/Output and user types. +/// +/// All four overloads dispatch through the registered +/// on , so AOT-safe and reflection-based +/// callers share a single code path. Callers wire AOT support by registering an +/// AOT-aware serializer with the runtime +/// (e.g., SourceGeneratorLambdaJsonSerializer<TContext>) — no per-call +/// JsonSerializerContext argument is required. /// -internal static class DurableEntryPointCore +public static class DurableFunction { - public static async Task InvokeAsync( + private static readonly Lazy _cachedLambdaClient = + new(() => new AmazonLambdaClient(), LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Wrap a workflow (typed input + output). + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) + => WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); + + /// + /// Wrap a workflow (typed input + output) with explicit Lambda client. + /// + public static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient) + => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient); + + /// + /// Wrap a void workflow (typed input, no output). + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext) + => WrapAsync(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); + + /// + /// Wrap a void workflow with explicit Lambda client. + /// + public static Task WrapAsync( + Func workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IAmazonLambda lambdaClient) + => WrapAsyncCore( + async (input, ctx) => { await workflow(input, ctx); return null; }, + invocationInput, lambdaContext, lambdaClient); + + private static async Task WrapAsyncCore( Func> workflow, - Stream input, + DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, IAmazonLambda lambdaClient) { var serializer = lambdaContext.Serializer ?? throw new InvalidOperationException( "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + - "In the class library programming model, register one with " + - "[assembly: LambdaSerializer(typeof(...))]. In an executable / custom " + - "runtime, pass it to LambdaBootstrapBuilder.Create(handler, serializer). " + - "In tests, set TestLambdaContext.Serializer."); - - var invocationInput = JsonSerializer.Deserialize(input, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationInput) - ?? throw new DurableExecutionException("Durable execution envelope is malformed: input stream produced a null envelope."); + "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + + "(or in tests, set TestLambdaContext.Serializer)."); var state = new ExecutionState(); state.LoadFromCheckpoint(invocationInput.InitialExecutionState); @@ -99,6 +144,21 @@ public static async Task InvokeAsyncInvalidParameterValueException with a message starting with /// "Invalid Checkpoint Token" is treated as transient — the service rejects a /// stale token but a retry with a fresh token will succeed. + /// + /// Only checkpoint-flush errors flow through this catch. There are two paths: + /// 1. A flush triggered synchronously from inside a user StepAsync call + /// (the user awaits EnqueueAsync → batch flush → SDK throws → service client + /// wraps). + /// 2. The final after the workflow returns. + /// + /// State-hydration errors (GetExecutionStateAsync) propagate as + /// too, but they are NOT caught here — they + /// flow up to the host so Lambda retries, matching Python's GetExecutionStateError + /// (which extends InvocationError). + /// + /// User-code SDK errors (e.g. an SDK call inside a Step body) are caught by + /// StepRunner and surfaced as StepException for the workflow's normal + /// step-failure handling. /// private static bool IsTerminalCheckpointError(AmazonServiceException ex) { diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs index 73461e12b..c1bf44403 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Enums.cs @@ -1,10 +1,9 @@ namespace Amazon.Lambda.DurableExecution; /// -/// The terminal status of a durable execution invocation. Appears on the wire -/// envelope and on HandlerResult. +/// The terminal status of a durable execution invocation. /// -internal enum InvocationStatus +public enum InvocationStatus { /// The workflow completed successfully. Succeeded, diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs index 480e55813..20acac47f 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/ErrorObject.cs @@ -3,10 +3,9 @@ namespace Amazon.Lambda.DurableExecution; /// -/// Serializable error representation stored in checkpoint state. Produced by the -/// entry point when a workflow throws and shipped on the wire envelope. +/// Serializable error representation stored in checkpoint state. /// -internal sealed class ErrorObject +public sealed class ErrorObject { /// /// The fully-qualified exception type name. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs index fb49d9e01..581b02a94 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs @@ -32,9 +32,11 @@ public interface IDurableContext /// /// Execute a step with automatic checkpointing. The step result is serialized /// to a checkpoint using the registered on - /// . AOT and reflection-based scenarios - /// share this single overload — the AOT story is determined by the registered - /// serializer (e.g., SourceGeneratorLambdaJsonSerializer<TContext>). + /// (typically configured via + /// LambdaBootstrapBuilder.Create(handler, serializer)). AOT and + /// reflection-based scenarios share this single overload — the AOT story is + /// determined by the registered serializer (e.g., + /// SourceGeneratorLambdaJsonSerializer<TContext>). /// Task StepAsync( Func> func, diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs deleted file mode 100644 index 8f5a3080e..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/DurableEnvelopeJsonContext.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Amazon.Lambda.DurableExecution.Internal; - -/// -/// Source-generated JSON context for the durable execution wire envelope. -/// Co-located with the envelope types so the source generator can see every -/// internal type the envelope reaches (operation details, status converter) — -/// user-side contexts cannot, which is why envelope (de)serialization stays -/// inside the library and the user's serializer is only invoked for -/// TInput/TOutput. -/// -[JsonSerializable(typeof(DurableExecutionInvocationInput))] -[JsonSerializable(typeof(DurableExecutionInvocationOutput))] -internal partial class DurableEnvelopeJsonContext : JsonSerializerContext { } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs deleted file mode 100644 index d6f2eb9d6..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/InvocationStatusConverter.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Amazon.Lambda.DurableExecution.Internal; - -/// -/// Concrete subclass for . Source-generator JSON -/// contexts can only instantiate converters that are concrete and parameterless -/// when referenced via [JsonConverter(typeof(...))]. -/// -internal sealed class InvocationStatusConverter : UpperSnakeCaseEnumConverter { } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs index a349a2ced..01d5cccf0 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs @@ -7,7 +7,7 @@ namespace Amazon.Lambda.DurableExecution.Internal; /// Converts between UPPER_SNAKE_CASE wire format (e.g., CHAINED_INVOKE) /// and PascalCase enum values (e.g., ChainedInvoke). /// -internal class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum +internal sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum { /// public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs index abb5cef36..2b846bff1 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.AotPublishTest/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -7,26 +8,27 @@ namespace Amazon.Lambda.DurableExecution.AotPublishTest; /// /// AOT publish smoke check. This program must publish under NativeAOT with -/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). -/// -/// The user-side intentionally registers ONLY the -/// workflow's input/output POCOs — no DurableExecutionInvocation* wire types. -/// Envelope (de)serialization is owned by the library's internal context, so any -/// internal-type leak from the public API surface would cause this project to fail -/// AOT publish (CS0053 / SYSLIB1218 / SYSLIB1220). +/// zero IL2026/IL3050 warnings (promoted to errors by the csproj). The serializer +/// registered with is the same one DurableExecution +/// reads via , so AOT-safety is fully determined +/// by the user's choice of serializer (here, ). /// public class Program { - private static readonly DurableEntryPoint _entry = new(WorkflowAsync); - public static async Task Main() { + var serializer = new SourceGeneratorLambdaJsonSerializer(); + Func> handler = HandlerAsync; await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) + .Create(handler, serializer) .Build() .RunAsync(); } + public static Task HandlerAsync( + DurableExecutionInvocationInput input, ILambdaContext context) => + DurableFunction.WrapAsync(WorkflowAsync, input, context); + private static async Task WorkflowAsync(OrderEvent input, IDurableContext context) { var validation = await context.StepAsync( @@ -59,6 +61,8 @@ public class ValidationResult } } +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] [JsonSerializable(typeof(Program.OrderEvent))] [JsonSerializable(typeof(Program.OrderResult))] [JsonSerializable(typeof(Program.ValidationResult))] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs deleted file mode 100644 index 890bcd4e5..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/AotWaitOnlyTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Linq; -using System.Text; -using Amazon.Lambda.Model; -using Xunit; -using Xunit.Abstractions; - -namespace Amazon.Lambda.DurableExecution.IntegrationTests; - -/// -/// Same wait-only workflow as , but the function image is -/// built with NativeAOT (PublishAot=true, SourceGeneratorLambdaJsonSerializer). Catches -/// regressions where DurableExecution code is JIT-safe but breaks when trimmed/AOT-compiled. -/// -public class AotWaitOnlyTest -{ - private readonly ITestOutputHelper _output; - public AotWaitOnlyTest(ITestOutputHelper output) => _output = output; - - [Fact] - public async Task WaitOnly_NativeAot() - { - await using var deployment = await DurableFunctionDeployment.CreateAsync( - DurableFunctionDeployment.FindTestFunctionDir("WaitOnlyAotFunction"), - "waitonlyaot", _output, useDockerPublish: true); - - var (invokeResponse, executionName) = await deployment.InvokeAsync("""{"orderId": "wait-only-aot"}"""); - var responsePayload = Encoding.UTF8.GetString(invokeResponse.Payload.ToArray()); - _output.WriteLine($"Response: {responsePayload}"); - - var arn = await deployment.FindDurableExecutionArnByNameAsync(executionName, TimeSpan.FromSeconds(60)); - Assert.NotNull(arn); - - var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(60)); - Assert.Equal("SUCCEEDED", status, ignoreCase: true); - - var history = await deployment.WaitForHistoryAsync( - arn!, - h => (h.Events?.Any(e => e.WaitSucceededDetails != null) ?? false), - TimeSpan.FromSeconds(60)); - var events = history.Events ?? new List(); - - var waitStarted = events.FirstOrDefault(e => e.WaitStartedDetails != null && e.Name == "only_wait"); - Assert.NotNull(waitStarted); - Assert.Equal(5, waitStarted!.WaitStartedDetails.Duration); - - var waitSucceeded = events.FirstOrDefault(e => e.WaitSucceededDetails != null && e.Name == "only_wait"); - Assert.NotNull(waitSucceeded); - - Assert.Empty(events.Where(e => e.StepStartedDetails != null)); - - var invocations = events.Where(e => e.InvocationCompletedDetails != null).ToList(); - Assert.True( - invocations.Count >= 2, - $"Expected at least 2 InvocationCompleted events (initial + post-wait resume), got {invocations.Count}"); - } -} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs index 783175d7a..8b5bb2e1b 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs @@ -52,13 +52,12 @@ private DurableFunctionDeployment(ITestOutputHelper output, string suffix) public static async Task CreateAsync( string testFunctionDir, string scenarioSuffix, - ITestOutputHelper output, - bool useDockerPublish = false) + ITestOutputHelper output) { var deployment = new DurableFunctionDeployment(output, scenarioSuffix); try { - await deployment.InitializeAsync(testFunctionDir, useDockerPublish); + await deployment.InitializeAsync(testFunctionDir); } catch { @@ -70,7 +69,7 @@ public static async Task CreateAsync( return deployment; } - private async Task InitializeAsync(string testFunctionDir, bool useDockerPublish) + private async Task InitializeAsync(string testFunctionDir) { // 1. Create IAM role _output.WriteLine($"Creating IAM role: {_roleName}"); @@ -118,7 +117,7 @@ await _iamClient.AttachRolePolicyAsync(new AttachRolePolicyRequest // 3. Build and push Docker image _output.WriteLine($"Building and pushing Docker image from {testFunctionDir}..."); - _imageUri = await BuildAndPushImage(testFunctionDir, repositoryUri, useDockerPublish); + _imageUri = await BuildAndPushImage(testFunctionDir, repositoryUri); _output.WriteLine($"Image pushed: {_imageUri}"); // 4. Create Lambda function @@ -308,148 +307,38 @@ private async Task WaitForFunctionActive() throw new TimeoutException("Function did not become Active within 120 seconds"); } - private async Task BuildAndPushImage(string testFunctionDir, string repositoryUri, bool useDockerPublish) + private async Task BuildAndPushImage(string testFunctionDir, string repositoryUri) { - var imageTag = $"{repositoryUri}:latest"; + var publishDir = Path.Combine(testFunctionDir, "bin", "publish"); + if (Directory.Exists(publishDir)) Directory.Delete(publishDir, true); - // Two flavors of `docker build`: - // - Host-publish (default, JIT functions): `dotnet publish` runs on the host and - // writes to bin/publish/, which the Dockerfile COPYs in. Build context = function dir. - // - Docker-publish (NativeAOT): the host can't cross-compile AOT for linux-x64 - // reliably (needs clang/zlib), so `dotnet publish` runs *inside* the image. - // The Dockerfile's project references reach back to Libraries\src\... and - // buildtools\common.props, so we stage those into a temp directory and use it - // as the build context. - string buildContextDir; - string dockerfilePath; - string? stagingDir = null; - - if (useDockerPublish) - { - stagingDir = StageBuildContextForDockerPublish(testFunctionDir); - buildContextDir = stagingDir; + await RunProcess("dotnet", + $"publish -c Release -r linux-x64 --self-contained true -o \"{publishDir}\"", + testFunctionDir); - // The function's project lives at the same relative path inside the staging dir. - var repoRoot = FindRepoRoot(testFunctionDir); - var relFunctionDir = Path.GetRelativePath(repoRoot, testFunctionDir); - dockerfilePath = Path.Combine(stagingDir, relFunctionDir, "Dockerfile"); - } - else - { - var publishDir = Path.Combine(testFunctionDir, "bin", "publish"); - if (Directory.Exists(publishDir)) Directory.Delete(publishDir, true); + var imageTag = $"{repositoryUri}:latest"; + await RunProcess("docker", + $"build --platform linux/amd64 --provenance=false -t {imageTag} .", + testFunctionDir); - await RunProcess("dotnet", - $"publish -c Release -r linux-x64 --self-contained true -o \"{publishDir}\"", - testFunctionDir); + var authResponse = await _ecrClient.GetAuthorizationTokenAsync(new GetAuthorizationTokenRequest()); + var authData = authResponse.AuthorizationData[0]; + var token = Encoding.UTF8.GetString(Convert.FromBase64String(authData.AuthorizationToken)); + var parts = token.Split(':'); + var registryUrl = authData.ProxyEndpoint; - buildContextDir = testFunctionDir; - dockerfilePath = Path.Combine(testFunctionDir, "Dockerfile"); - } + await RunProcess("docker", + $"login --username {parts[0]} --password-stdin {registryUrl}", + testFunctionDir, + stdin: parts[1]); - try - { - await RunProcess("docker", - $"build --platform linux/amd64 --provenance=false -f \"{dockerfilePath}\" -t {imageTag} \"{buildContextDir}\"", - buildContextDir, - timeout: useDockerPublish ? TimeSpan.FromMinutes(20) : TimeSpan.FromMinutes(5)); - - var authResponse = await _ecrClient.GetAuthorizationTokenAsync(new GetAuthorizationTokenRequest()); - var authData = authResponse.AuthorizationData[0]; - var token = Encoding.UTF8.GetString(Convert.FromBase64String(authData.AuthorizationToken)); - var parts = token.Split(':'); - var registryUrl = authData.ProxyEndpoint; - - await RunProcess("docker", - $"login --username {parts[0]} --password-stdin {registryUrl}", - buildContextDir, - stdin: parts[1]); - - await RunProcess("docker", $"push {imageTag}", buildContextDir); - } - finally - { - if (stagingDir != null && Directory.Exists(stagingDir)) - { - try { Directory.Delete(stagingDir, true); } - catch (Exception ex) { _output.WriteLine($"Cleanup error (staging dir): {ex.Message}"); } - } - } + await RunProcess("docker", $"push {imageTag}", testFunctionDir); return imageTag; } - /// - /// Copies the minimum source tree needed to publish a NativeAOT durable function inside - /// a Docker container: buildtools/ (pulled in by Libraries\src\...\common.props), - /// Libraries/src/ (project references), and the function dir itself, all preserved - /// at their original relative paths so ProjectReferences resolve. - /// - private static string StageBuildContextForDockerPublish(string testFunctionDir) - { - var repoRoot = FindRepoRoot(testFunctionDir); - var stagingRoot = Path.Combine(Path.GetTempPath(), $"durable-aot-stage-{Guid.NewGuid():N}"); - Directory.CreateDirectory(stagingRoot); - - CopyDirectoryFiltered(Path.Combine(repoRoot, "buildtools"), Path.Combine(stagingRoot, "buildtools")); - CopyDirectoryFiltered(Path.Combine(repoRoot, "Libraries", "src"), Path.Combine(stagingRoot, "Libraries", "src")); - - var relFunctionDir = Path.GetRelativePath(repoRoot, testFunctionDir); - CopyDirectoryFiltered(testFunctionDir, Path.Combine(stagingRoot, relFunctionDir)); - - return stagingRoot; - } - - private static string FindRepoRoot(string startDir) - { - var dir = new DirectoryInfo(Path.GetFullPath(startDir)); - while (dir != null) - { - if (Directory.Exists(Path.Combine(dir.FullName, "buildtools")) && - Directory.Exists(Path.Combine(dir.FullName, "Libraries"))) - { - return dir.FullName; - } - dir = dir.Parent; - } - throw new DirectoryNotFoundException( - $"Could not locate repo root (with buildtools/ and Libraries/) starting from {startDir}"); - } - - private static void CopyDirectoryFiltered(string source, string destination) - { - if (!Directory.Exists(source)) - throw new DirectoryNotFoundException($"Source directory not found: {source}"); - - Directory.CreateDirectory(destination); - foreach (var dirPath in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) - { - // Skip build artifacts — they bloat the docker context and AOT publish needs a clean obj/. - var rel = Path.GetRelativePath(source, dirPath); - if (rel.Contains("bin", StringComparison.OrdinalIgnoreCase) || - rel.Contains("obj", StringComparison.OrdinalIgnoreCase) || - rel.Contains(".vs", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - Directory.CreateDirectory(Path.Combine(destination, rel)); - } - foreach (var filePath in Directory.GetFiles(source, "*", SearchOption.AllDirectories)) - { - var rel = Path.GetRelativePath(source, filePath); - if (rel.Contains("bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - rel.Contains("obj" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || - rel.Contains(".vs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - File.Copy(filePath, Path.Combine(destination, rel), overwrite: true); - } - } - - private async Task RunProcess(string fileName, string arguments, string workingDir, string? stdin = null, TimeSpan? timeout = null) + private async Task RunProcess(string fileName, string arguments, string workingDir, string? stdin = null) { - var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5); _output.WriteLine($"Running: {fileName} {arguments}"); var psi = new System.Diagnostics.ProcessStartInfo { @@ -475,12 +364,12 @@ private async Task RunProcess(string fileName, string arguments, string workingD await Task.WhenAny( process.WaitForExitAsync(), - Task.Delay(effectiveTimeout)); + Task.Delay(TimeSpan.FromMinutes(5))); if (!process.HasExited) { process.Kill(); - throw new TimeoutException($"{fileName} timed out after {effectiveTimeout.TotalMinutes} minutes"); + throw new TimeoutException($"{fileName} timed out after 5 minutes"); } var stdout = await stdoutTask; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs index eadb1b7cf..e73a6da7e 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/LongerWaitFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"started-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs index 644ebabfa..cc80e6afa 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/MultipleStepsFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"a-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs index d4d0a4fde..ce2a333b1 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/ReplayDeterminismFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { // Step 1 generates a fresh GUID. On replay, this MUST return the cached value. var generatedId = await context.StepAsync( diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs index 33c06c4e5..9aeeed2a2 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepFailsFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { await context.StepAsync( async (_) => diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs index 7bc16c663..5b6c291df 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/StepWaitStepFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { var step1 = await context.StepAsync( async (_) => { await Task.CompletedTask; return $"validated-{input.OrderId}"; }, diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile deleted file mode 100644 index 5c19cf43b..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# Multi-stage build: NativeAOT must be compiled on the target Linux toolchain. -# Build context is staged by the integration test (DurableFunctionDeployment.cs) -# and contains: buildtools/, Libraries/src/, and this function dir under -# Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/ -# so the project's relative ProjectReferences (..\..\..\..\src\...) resolve. - -FROM public.ecr.aws/amazonlinux/amazonlinux:2023 AS build - -RUN dnf install -y \ - clang \ - zlib-devel \ - krb5-libs \ - openssl-libs \ - tar \ - gzip \ - libicu \ - && dnf clean all - -# Install the .NET 8, 9, and 10 SDKs. dotnet-install.sh works on AL2023 where -# `dnf install dotnet-sdk-*` may not be available. -# All three SDKs are required: the AOT project targets net8.0, but its ProjectReferences -# target net9.0 (RuntimeSupport, SnapshotRestore.Registry) and net10.0 (Core, -# DurableExecution, Serialization.SystemTextJson). Restore enumerates every TFM of -# every referenced project, so all SDKs must be present even when only net8.0 builds. -RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ - && chmod +x /tmp/dotnet-install.sh \ - && /tmp/dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet \ - && /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \ - && /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \ - && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ - && rm /tmp/dotnet-install.sh - -WORKDIR /src -COPY . . - -WORKDIR /src/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction -RUN dotnet publish -c Release -r linux-x64 -o /publish - -FROM public.ecr.aws/lambda/provided:al2023 -RUN dnf install -y libicu && dnf clean all -COPY --from=build /publish/ ${LAMBDA_TASK_ROOT} -ENTRYPOINT ["/var/task/bootstrap"] diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs deleted file mode 100644 index 0b7faf7aa..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/Function.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; -using Amazon.Lambda.DurableExecution; -using Amazon.Lambda.RuntimeSupport; -using Amazon.Lambda.Serialization.SystemTextJson; - -namespace DurableExecutionAotTestFunction; - -public class Function -{ - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() - { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new SourceGeneratorLambdaJsonSerializer()) - .Build() - .RunAsync(); - } - - private static async Task Workflow(TestEvent input, IDurableContext context) - { - await context.WaitAsync(TimeSpan.FromSeconds(5), name: "only_wait"); - return new TestResult { Status = "completed", Data = "wait_only" }; - } -} - -public class TestEvent { public string? OrderId { get; set; } } -public class TestResult { public string? Status { get; set; } public string? Data { get; set; } } - -[JsonSerializable(typeof(TestEvent))] -[JsonSerializable(typeof(TestResult))] -public partial class AotJsonContext : JsonSerializerContext -{ -} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj deleted file mode 100644 index f39bb0e7d..000000000 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyAotFunction/WaitOnlyAotFunction.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net8.0 - Exe - bootstrap - enable - enable - true - true - true - full - false - true - IL2026,IL2067,IL2075,IL3050 - - - - - - - - - - diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs index d8ab6744c..54e4ab737 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/TestFunctions/WaitOnlyFunction/Function.cs @@ -1,3 +1,4 @@ +using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; @@ -6,17 +7,20 @@ namespace DurableExecutionTestFunction; public class Function { - private static readonly DurableEntryPoint _entry = new(Workflow); - - public static async Task Main() + public static async Task Main(string[] args) { - await LambdaBootstrapBuilder - .Create(_entry.InvokeAsync, new DefaultLambdaJsonSerializer()) - .Build() - .RunAsync(); + var handler = new Function(); + var serializer = new DefaultLambdaJsonSerializer(); + using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(handlerWrapper); + await bootstrap.RunAsync(); } - private static async Task Workflow(TestEvent input, IDurableContext context) + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(TestEvent input, IDurableContext context) { await context.WaitAsync(TimeSpan.FromSeconds(5), name: "only_wait"); return new TestResult { Status = "completed", Data = "wait_only" }; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs similarity index 77% rename from Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs rename to Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index 33c478f2b..f30a302de 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableEntryPointTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -1,8 +1,6 @@ -using System.IO; using System.Net; using System.Text.Json; using Amazon.Lambda; -using Amazon.Lambda.Core; using Amazon.Lambda.DurableExecution; using Amazon.Lambda.DurableExecution.Internal; using Amazon.Lambda.Serialization.SystemTextJson; @@ -16,14 +14,7 @@ namespace Amazon.Lambda.DurableExecution.Tests; -/// -/// Drives through its public -/// Stream → Stream contract. Builds an internal envelope POCO, -/// serializes it via the library's internal envelope context (mirroring what the -/// real Lambda runtime delivers on the wire), then asserts on the deserialized -/// output envelope. InternalsVisibleTo lets us reach the internal types. -/// -public class DurableEntryPointTests +public class DurableFunctionTests { /// Reproduces the Id that emits for the n-th root-level operation. private static string IdAt(int position) => OperationIdGenerator.HashOperationId(position.ToString()); @@ -35,28 +26,8 @@ private static TestLambdaContext CreateLambdaContext() => private readonly IAmazonLambda _mockClient = new MockLambdaClient(); - private static MemoryStream EnvelopeStream(DurableExecutionInvocationInput input) - { - var ms = new MemoryStream(); - JsonSerializer.Serialize(ms, input, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationInput); - ms.Position = 0; - return ms; - } - - private static async Task InvokeAsync( - Func> workflow, - DurableExecutionInvocationInput input, - ILambdaContext ctx, - IAmazonLambda lambdaClient) - { - var entry = new DurableEntryPoint(workflow, lambdaClient); - using var inStream = EnvelopeStream(input); - var outStream = await entry.InvokeAsync(inStream, ctx); - return JsonSerializer.Deserialize(outStream, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput)!; - } - [Fact] - public async Task FreshExecution_StepThenWait_ReturnsPending() + public async Task WrapAsync_FreshExecution_StepThenWait_ReturnsPending() { var input = new DurableExecutionInvocationInput { @@ -76,14 +47,17 @@ public async Task FreshExecution_StepThenWait_ReturnsPending() } }; - var output = await InvokeAsync( - MyWorkflow, input, CreateLambdaContext(), _mockClient); + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + CreateLambdaContext(), + _mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); } [Fact] - public async Task ReplayWithElapsedWait_ReturnsSucceeded() + public async Task WrapAsync_ReplayWithElapsedWait_ReturnsSucceeded() { var pastExpirationMs = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(); var input = new DurableExecutionInvocationInput @@ -118,8 +92,11 @@ public async Task ReplayWithElapsedWait_ReturnsSucceeded() } }; - var output = await InvokeAsync( - MyWorkflow, input, CreateLambdaContext(), _mockClient); + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + CreateLambdaContext(), + _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.NotNull(output.Result); @@ -128,7 +105,7 @@ public async Task ReplayWithElapsedWait_ReturnsSucceeded() } [Fact] - public async Task WorkflowThrows_ReturnsFailed() + public async Task WrapAsync_WorkflowThrows_ReturnsFailed() { var input = new DurableExecutionInvocationInput { @@ -148,9 +125,11 @@ public async Task WorkflowThrows_ReturnsFailed() } }; - var output = await InvokeAsync( + var output = await DurableFunction.WrapAsync( async (evt, ctx) => throw new InvalidOperationException("workflow error"), - input, CreateLambdaContext(), _mockClient); + input, + CreateLambdaContext(), + _mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); Assert.NotNull(output.Error); @@ -159,7 +138,7 @@ public async Task WorkflowThrows_ReturnsFailed() } [Fact] - public async Task VoidWorkflow_ReturnsSucceeded() + public async Task WrapAsync_VoidWorkflow_ReturnSucceeded() { var input = new DurableExecutionInvocationInput { @@ -180,23 +159,21 @@ public async Task VoidWorkflow_ReturnsSucceeded() }; var executed = false; - var entry = new DurableEntryPoint( + var output = await DurableFunction.WrapAsync( async (evt, ctx) => { await ctx.StepAsync(async (_) => { await Task.CompletedTask; executed = true; }, name: "do_work"); }, + input, + CreateLambdaContext(), _mockClient); - using var inStream = EnvelopeStream(input); - var outStream = await entry.InvokeAsync(inStream, CreateLambdaContext()); - var output = JsonSerializer.Deserialize(outStream, DurableEnvelopeJsonContext.Default.DurableExecutionInvocationOutput)!; - Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.True(executed); } [Fact] - public async Task CheckpointsAreSentToService() + public async Task WrapAsync_CheckpointsAreSentToService() { var mockClient = new MockLambdaClient(); var input = new DurableExecutionInvocationInput @@ -218,8 +195,11 @@ public async Task CheckpointsAreSentToService() } }; - var output = await InvokeAsync( - MyWorkflow, input, CreateLambdaContext(), mockClient); + var output = await DurableFunction.WrapAsync( + MyWorkflow, + input, + CreateLambdaContext(), + mockClient); Assert.Equal(InvocationStatus.Pending, output.Status); Assert.Equal(2, mockClient.CheckpointCalls.Count); @@ -249,10 +229,10 @@ public async Task CheckpointsAreSentToService() } [Fact] - public async Task UserPayload_BindsCamelCaseToPascalCaseProperty() + public async Task WrapAsync_UserPayload_BindsCamelCaseToPascalCaseProperty() { // The wire payload uses camelCase ("orderId"), the user POCO uses PascalCase (OrderId). - // Stage-2 deserialization must do case-insensitive binding so workflows can read input.OrderId. + // ExtractUserPayload must do case-insensitive binding so workflows can read input.OrderId. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:case-test", @@ -272,25 +252,27 @@ public async Task UserPayload_BindsCamelCaseToPascalCaseProperty() }; string? observedOrderId = null; - var output = await InvokeAsync( + var output = await DurableFunction.WrapAsync( async (evt, ctx) => { observedOrderId = evt.OrderId; await Task.CompletedTask; return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, - input, CreateLambdaContext(), _mockClient); + input, + CreateLambdaContext(), + _mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); Assert.Equal("abc-123", observedOrderId); } [Fact] - public async Task NoExecutionOp_ThrowsMalformedEnvelope() + public async Task WrapAsync_NoExecutionOp_ThrowsMalformedEnvelope() { - // No EXECUTION operation in the envelope — DurableEntryPointCore.ExtractUserPayload must - // throw a typed DurableExecutionException so the malformed envelope surfaces as a clear - // error instead of leaking default!/null into user code as a NullReferenceException. + // No EXECUTION operation in the envelope — ExtractUserPayload must throw a typed + // DurableExecutionException so the malformed envelope surfaces as a clear error + // instead of leaking default!/null into user code as a NullReferenceException. var input = new DurableExecutionInvocationInput { DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:no-exec", @@ -300,26 +282,29 @@ public async Task NoExecutionOp_ThrowsMalformedEnvelope() } }; - var entry = new DurableEntryPoint( - async (evt, ctx) => { await Task.CompletedTask; return new OrderResult { Status = "ok" }; }, - _mockClient); - - using var inStream = EnvelopeStream(input); - var ex = await Assert.ThrowsAsync( - () => entry.InvokeAsync(inStream, CreateLambdaContext())); + var ex = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + async (evt, ctx) => + { + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + CreateLambdaContext(), + _mockClient)); Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("EXECUTION", ex.Message); } [Fact] - public async Task PaginatedInitialState_HydratesAllPages() + public async Task WrapAsync_PaginatedInitialState_HydratesAllPages() { // The service can return execution state across multiple pages — the first // page comes inline on the invocation envelope (InitialExecutionState) and // subsequent pages must be fetched via GetDurableExecutionState. Verify the - // pagination loop in DurableEntryPointCore walks every page so the workflow - // sees the full operation history on replay. + // pagination loop in WrapAsyncCore (DurableFunction.cs:160-167) walks every + // page so the workflow sees the full operation history on replay. var arn = "arn:aws:lambda:us-east-1:123:durable-execution:paginated"; // Page 0 (in InitialExecutionState): EXECUTION op + step1 SUCCEEDED. @@ -389,7 +374,7 @@ public async Task PaginatedInitialState_HydratesAllPages() }; var observed = new List(); - var output = await InvokeAsync( + var output = await DurableFunction.WrapAsync( async (evt, ctx) => { // All three steps must replay the cached results from the paginated state @@ -403,7 +388,9 @@ public async Task PaginatedInitialState_HydratesAllPages() async (_) => { await Task.CompletedTask; return "fresh"; }, name: "step3")); return new OrderResult { Status = "ok", OrderId = evt.OrderId }; }, - input, CreateLambdaContext(), mockClient); + input, + CreateLambdaContext(), + mockClient); Assert.Equal(InvocationStatus.Succeeded, output.Status); @@ -422,7 +409,7 @@ public async Task PaginatedInitialState_HydratesAllPages() } [Fact] - public async Task NullInitialExecutionState_ThrowsMalformedEnvelope() + public async Task WrapAsync_NullInitialExecutionState_ThrowsMalformedEnvelope() { // No initial execution state at all — same malformed-envelope branch in ExtractUserPayload. var input = new DurableExecutionInvocationInput @@ -430,50 +417,18 @@ public async Task NullInitialExecutionState_ThrowsMalformedEnvelope() DurableExecutionArn = "arn:aws:lambda:us-east-1:123:durable-execution:null-state" }; - var entry = new DurableEntryPoint( - async (evt, ctx) => { await Task.CompletedTask; return new OrderResult { Status = "ok" }; }, - _mockClient); - - using var inStream = EnvelopeStream(input); - var ex = await Assert.ThrowsAsync( - () => entry.InvokeAsync(inStream, CreateLambdaContext())); - - Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task NoSerializerOnContext_ThrowsHelpfulException() - { - // Stage 2 (user payload (de)serialization) requires a serializer registered on - // ILambdaContext.Serializer. Without one, the entry point should throw a - // self-explanatory InvalidOperationException rather than NRE on the next line. - var input = new DurableExecutionInvocationInput - { - DurableExecutionArn = "arn:test", - InitialExecutionState = new InitialExecutionState - { - Operations = new List + var ex = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + async (evt, ctx) => { - new() - { - Id = "exec-0", - Type = OperationTypes.Execution, - Status = OperationStatuses.Started, - ExecutionDetails = new ExecutionDetails { InputPayload = "{\"orderId\":\"x\"}" } - } - } - } - }; - - var entry = new DurableEntryPoint( - async (evt, ctx) => { await Task.CompletedTask; return new OrderResult(); }, - _mockClient); - - using var inStream = EnvelopeStream(input); - var ex = await Assert.ThrowsAsync( - () => entry.InvokeAsync(inStream, new TestLambdaContext())); + await Task.CompletedTask; + return new OrderResult { Status = "ok" }; + }, + input, + CreateLambdaContext(), + _mockClient)); - Assert.Contains("ILambdaContext.Serializer", ex.Message); + Assert.Contains("malformed", ex.Message, StringComparison.OrdinalIgnoreCase); } // ────────────────────────────────────────────────────────────────────── @@ -484,7 +439,7 @@ public async Task NoSerializerOnContext_ThrowsHelpfulException() // Carve-out: InvalidParameterValueException "Invalid Checkpoint Token" → transient // // Driven through CheckpointDurableExecution: a workflow that succeeds a single Step - // forces the batcher to flush, which is wrapped by the try/catch in DurableEntryPointCore. + // forces the batcher to flush, which is wrapped by the try/catch in WrapAsyncCore. // ────────────────────────────────────────────────────────────────────── public static IEnumerable TerminalCheckpointErrorCases() => new[] @@ -498,12 +453,15 @@ public static IEnumerable TerminalCheckpointErrorCases() => new[] [Theory] [MemberData(nameof(TerminalCheckpointErrorCases))] - public async Task CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) + public async Task WrapAsync_CheckpointThrowsTerminal_ReturnsFailed(AmazonServiceException ex) { + // LambdaDurableServiceClient now wraps SDK exceptions in DurableExecutionException + // so user logs carry context (which call, which ARN). The outer message includes + // the inner SDK message; the classifier matches on the wrapper's InnerException. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; - var output = await InvokeAsync( + var output = await DurableFunction.WrapAsync( SingleStepWorkflow, input, CreateLambdaContext(), mockClient); Assert.Equal(InvocationStatus.Failed, output.Status); @@ -527,21 +485,25 @@ public static IEnumerable TransientCheckpointErrorCases() => new[] [Theory] [MemberData(nameof(TransientCheckpointErrorCases))] - public async Task CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) + public async Task WrapAsync_CheckpointThrowsTransient_PropagatesToHost(AmazonServiceException ex) { + // Transient SDK errors escape the IsTerminalCheckpointError catch and propagate + // to the host as DurableExecutionException wrapping the original SDK exception + // — Lambda's normal retry semantics fire on the wrapper. The original SDK + // exception is preserved as InnerException so callers can still introspect + // the original status code / error code. var input = MakeCheckpointInput(); var mockClient = new MockLambdaClient { CheckpointThrows = ex }; - var entry = new DurableEntryPoint(SingleStepWorkflow, mockClient); - using var inStream = EnvelopeStream(input); - var thrown = await Assert.ThrowsAsync( - () => entry.InvokeAsync(inStream, CreateLambdaContext())); + var thrown = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + SingleStepWorkflow, input, CreateLambdaContext(), mockClient)); Assert.Same(ex, thrown.InnerException); } [Fact] - public async Task HydrationThrows_AlwaysPropagatesToHost() + public async Task WrapAsync_HydrationThrows_AlwaysPropagatesToHost() { // State hydration is OUTSIDE the IsTerminalCheckpointError try/catch — every // GetExecutionStateAsync failure escapes for Lambda retry, matching Python's @@ -567,11 +529,13 @@ public async Task HydrationThrows_AlwaysPropagatesToHost() }; var ex = MakeServiceException("ResourceNotFoundException", HttpStatusCode.NotFound, "ARN gone"); var mockClient = new MockLambdaClient { GetExecutionStateThrows = ex }; - var entry = new DurableEntryPoint(MyWorkflow, mockClient); - using var inStream = EnvelopeStream(input); - var thrown = await Assert.ThrowsAsync( - () => entry.InvokeAsync(inStream, CreateLambdaContext())); + // Hydration errors are wrapped in DurableExecutionException by + // LambdaDurableServiceClient.GetExecutionStateAsync but are NOT caught by the + // IsTerminalCheckpointError filter, so they escape to the host. + var thrown = await Assert.ThrowsAsync(() => + DurableFunction.WrapAsync( + MyWorkflow, input, CreateLambdaContext(), mockClient)); Assert.Same(ex, thrown.InnerException); Assert.Contains("Failed to fetch execution state", thrown.Message); From 106fbe8b0d343005ffab703facd19f16584ed6da Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 16:37:03 -0400 Subject: [PATCH 13/16] Revert "Add stream-stream + serializer overload to LambdaBootstrapBuilder" This reverts commit 15c011ed6d8918e56805c5c4beb036f16536761e. --- .../e1a240df-673e-4a7d-af74-197103533038.json | 11 ----------- .../Bootstrap/HandlerWrapper.cs | 19 ------------------- .../Bootstrap/LambdaBootstrapBuilder.cs | 15 --------------- 3 files changed, 45 deletions(-) delete mode 100644 .autover/changes/e1a240df-673e-4a7d-af74-197103533038.json diff --git a/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json b/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json deleted file mode 100644 index 8d0fd5d42..000000000 --- a/.autover/changes/e1a240df-673e-4a7d-af74-197103533038.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.RuntimeSupport", - "Type": "Minor", - "ChangelogMessages": [ - "Add LambdaBootstrapBuilder.Create(Func>, ILambdaSerializer) overload (and matching HandlerWrapper.GetHandlerWrapper) so stream-stream handlers can expose a serializer via ILambdaContext.Serializer. Enables frameworks that own envelope (de)serialization but delegate inner-payload (de)serialization to a user-supplied serializer." - ] - } - ] -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs index 4d494413c..1981d5509 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs @@ -259,25 +259,6 @@ public static HandlerWrapper GetHandlerWrapper(Func - /// Get a HandlerWrapper for a stream-stream handler that also wants the supplied - /// exposed via . - /// The serializer is not used to (de)serialize the input/output streams — the handler - /// owns those — it is only made available on the context for handlers that perform - /// their own envelope (de)serialization and need to delegate an inner payload to a - /// user-supplied serializer. - /// - /// Func called for each invocation of the Lambda function. - /// ILambdaSerializer made available via . - /// A HandlerWrapper - public static HandlerWrapper GetHandlerWrapper(Func> handler, ILambdaSerializer serializer) - { - return new HandlerWrapper(async (invocation) => - { - return new InvocationResponse(await handler(invocation.InputStream, invocation.LambdaContext)); - }) { Serializer = serializer }; - } - /// /// Get a HandlerWrapper that will call the given method on function invocation. /// Note that you may have to cast your handler to its specific type to help the compiler. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs index 33c500256..fff7710ca 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrapBuilder.cs @@ -162,21 +162,6 @@ public static LambdaBootstrapBuilder Create(Func - /// Create a builder for creating the LambdaBootstrap. Use this overload for handlers - /// that consume and produce raw s but still want the supplied - /// exposed via - /// — useful for frameworks that perform their own envelope (de)serialization and - /// invoke the user's serializer for an inner payload. - /// - /// The handler that will be called for each Lambda invocation - /// The Lambda serializer made available via . Not used to (de)serialize the input/output streams themselves. - /// - public static LambdaBootstrapBuilder Create(Func> handler, ILambdaSerializer serializer) - { - return new LambdaBootstrapBuilder(HandlerWrapper.GetHandlerWrapper(handler, serializer)); - } - /// /// Create a builder for creating the LambdaBootstrap. /// From 72f8824281f581dd7f518a4d254d1249194b9e73 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 16:44:34 -0400 Subject: [PATCH 14/16] make public --- .../DurableExecutionInvocationInput.cs | 14 +- .../DurableExecutionInvocationOutput.cs | 2 - .../Internal/Operation.cs | 140 ------------- .../Operation.cs | 196 ++++++++++++++++++ .../Services/LambdaDurableServiceClient.cs | 18 +- .../UpperSnakeCaseEnumConverter.cs | 4 +- .../DurableFunctionTests.cs | 4 - .../ExecutionStateTests.cs | 3 +- 8 files changed, 215 insertions(+), 166 deletions(-) delete mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs create mode 100644 Libraries/src/Amazon.Lambda.DurableExecution/Operation.cs rename Libraries/src/Amazon.Lambda.DurableExecution/{Internal => }/UpperSnakeCaseEnumConverter.cs (92%) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs index 35bc32ecd..0f8894987 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationInput.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Amazon.Lambda.DurableExecution.Internal; namespace Amazon.Lambda.DurableExecution; @@ -22,21 +21,18 @@ public sealed class DurableExecutionInvocationInput public string? CheckpointToken { get; set; } /// - /// Previously checkpointed operation state for replay. Internal — consumed - /// only by DurableFunction.WrapAsync for replay correlation; user code - /// should never read or modify this. Marked - /// so System.Text.Json populates it during deserialization despite being internal - /// (framework needs it, but it's not part of the public API contract). + /// Previously checkpointed operation state for replay. Consumed by + /// DurableFunction.WrapAsync for replay correlation; user code + /// should not modify this on a live invocation envelope. /// [JsonPropertyName("InitialExecutionState")] - [JsonInclude] - internal InitialExecutionState? InitialExecutionState { get; set; } + public InitialExecutionState? InitialExecutionState { get; set; } } /// /// The previously checkpointed execution state provided on replay invocations. /// -internal sealed class InitialExecutionState +public sealed class InitialExecutionState { /// /// The list of operations from prior invocations. diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs index 0e187e015..715f2bc37 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableExecutionInvocationOutput.cs @@ -1,6 +1,4 @@ -using System.Text.Json; using System.Text.Json.Serialization; -using Amazon.Lambda.DurableExecution.Internal; namespace Amazon.Lambda.DurableExecution; diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs deleted file mode 100644 index 473c7a3b2..000000000 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/Operation.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Amazon.Lambda.DurableExecution.Internal; - -/// -/// One operation in the durable execution service's invocation envelope. -/// Property names mirror the wire format exactly so System.Text.Json can -/// populate this type declaratively. Internal — consumed by ExecutionState -/// and DurableContext during replay; never exposed on a public surface. -/// -internal sealed class Operation -{ - [JsonPropertyName("Id")] - public string? Id { get; set; } - - [JsonPropertyName("Type")] - public string? Type { get; set; } - - [JsonPropertyName("Status")] - public string? Status { get; set; } - - [JsonPropertyName("Name")] - public string? Name { get; set; } - - [JsonPropertyName("ParentId")] - public string? ParentId { get; set; } - - [JsonPropertyName("SubType")] - public string? SubType { get; set; } - - [JsonPropertyName("StartTimestamp")] - public long? StartTimestamp { get; set; } - - [JsonPropertyName("EndTimestamp")] - public long? EndTimestamp { get; set; } - - [JsonPropertyName("StepDetails")] - public StepDetails? StepDetails { get; set; } - - [JsonPropertyName("WaitDetails")] - public WaitDetails? WaitDetails { get; set; } - - [JsonPropertyName("ExecutionDetails")] - public ExecutionDetails? ExecutionDetails { get; set; } - - [JsonPropertyName("CallbackDetails")] - public CallbackDetails? CallbackDetails { get; set; } - - [JsonPropertyName("ChainedInvokeDetails")] - public ChainedInvokeDetails? ChainedInvokeDetails { get; set; } - - [JsonPropertyName("ContextDetails")] - public ContextDetails? ContextDetails { get; set; } -} - -internal sealed class StepDetails -{ - [JsonPropertyName("Result")] - public string? Result { get; set; } - - [JsonPropertyName("Error")] - public ErrorObject? Error { get; set; } - - [JsonPropertyName("Attempt")] - public int? Attempt { get; set; } - - [JsonPropertyName("NextAttemptTimestamp")] - public long? NextAttemptTimestamp { get; set; } -} - -internal sealed class WaitDetails -{ - [JsonPropertyName("ScheduledEndTimestamp")] - public long? ScheduledEndTimestamp { get; set; } -} - -internal sealed class ExecutionDetails -{ - [JsonPropertyName("InputPayload")] - public string? InputPayload { get; set; } -} - -internal sealed class CallbackDetails -{ - [JsonPropertyName("CallbackId")] - public string? CallbackId { get; set; } - - [JsonPropertyName("Result")] - public string? Result { get; set; } - - [JsonPropertyName("Error")] - public ErrorObject? Error { get; set; } -} - -internal sealed class ChainedInvokeDetails -{ - [JsonPropertyName("Result")] - public string? Result { get; set; } - - [JsonPropertyName("Error")] - public ErrorObject? Error { get; set; } -} - -internal sealed class ContextDetails -{ - [JsonPropertyName("Result")] - public string? Result { get; set; } - - [JsonPropertyName("Error")] - public ErrorObject? Error { get; set; } -} - -/// -/// Wire-format string constants. -/// Plural name avoids collision with Amazon.Lambda.OperationType. -/// -internal static class OperationTypes -{ - public const string Step = "STEP"; - public const string Wait = "WAIT"; - public const string Callback = "CALLBACK"; - public const string ChainedInvoke = "CHAINED_INVOKE"; - public const string Context = "CONTEXT"; - public const string Execution = "EXECUTION"; -} - -/// -/// Wire-format string constants. -/// Plural name avoids collision with Amazon.Lambda.OperationStatus. -/// -internal static class OperationStatuses -{ - public const string Started = "STARTED"; - public const string Succeeded = "SUCCEEDED"; - public const string Failed = "FAILED"; - public const string Pending = "PENDING"; - public const string Cancelled = "CANCELLED"; - public const string Ready = "READY"; - public const string Stopped = "STOPPED"; -} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Operation.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Operation.cs new file mode 100644 index 000000000..7237eef87 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Operation.cs @@ -0,0 +1,196 @@ +using System.Text.Json.Serialization; + +namespace Amazon.Lambda.DurableExecution; + +/// +/// One operation in the durable execution service's invocation envelope. +/// Property names mirror the wire format exactly so System.Text.Json can +/// populate this type declaratively. +/// +public sealed class Operation +{ + /// The operation's unique identifier. + [JsonPropertyName("Id")] + public string? Id { get; set; } + + /// Operation type — see . + [JsonPropertyName("Type")] + public string? Type { get; set; } + + /// Operation status — see . + [JsonPropertyName("Status")] + public string? Status { get; set; } + + /// User-supplied operation name (e.g., the step name). + [JsonPropertyName("Name")] + public string? Name { get; set; } + + /// Identifier of the parent operation, if any (used for nested contexts). + [JsonPropertyName("ParentId")] + public string? ParentId { get; set; } + + /// Operation sub-type, if any (e.g., for child contexts). + [JsonPropertyName("SubType")] + public string? SubType { get; set; } + + /// Unix-epoch milliseconds at which the operation started. + [JsonPropertyName("StartTimestamp")] + public long? StartTimestamp { get; set; } + + /// Unix-epoch milliseconds at which the operation ended. + [JsonPropertyName("EndTimestamp")] + public long? EndTimestamp { get; set; } + + /// Step-specific details (present when is STEP). + [JsonPropertyName("StepDetails")] + public StepDetails? StepDetails { get; set; } + + /// Wait-specific details (present when is WAIT). + [JsonPropertyName("WaitDetails")] + public WaitDetails? WaitDetails { get; set; } + + /// Execution-specific details (present when is EXECUTION). + [JsonPropertyName("ExecutionDetails")] + public ExecutionDetails? ExecutionDetails { get; set; } + + /// Callback-specific details (present when is CALLBACK). + [JsonPropertyName("CallbackDetails")] + public CallbackDetails? CallbackDetails { get; set; } + + /// Chained-invoke details (present when is CHAINED_INVOKE). + [JsonPropertyName("ChainedInvokeDetails")] + public ChainedInvokeDetails? ChainedInvokeDetails { get; set; } + + /// Child-context details (present when is CONTEXT). + [JsonPropertyName("ContextDetails")] + public ContextDetails? ContextDetails { get; set; } +} + +/// Details for a STEP operation. +public sealed class StepDetails +{ + /// Serialized step result. + [JsonPropertyName("Result")] + public string? Result { get; set; } + + /// Error from the most recent attempt, if it failed. + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } + + /// The attempt number (1-based). + [JsonPropertyName("Attempt")] + public int? Attempt { get; set; } + + /// Unix-epoch milliseconds at which the next retry attempt is scheduled. + [JsonPropertyName("NextAttemptTimestamp")] + public long? NextAttemptTimestamp { get; set; } +} + +/// Details for a WAIT operation. +public sealed class WaitDetails +{ + /// Unix-epoch milliseconds at which the wait is scheduled to end. + [JsonPropertyName("ScheduledEndTimestamp")] + public long? ScheduledEndTimestamp { get; set; } +} + +/// Details for an EXECUTION operation. +public sealed class ExecutionDetails +{ + /// The serialized user input payload for this invocation. + [JsonPropertyName("InputPayload")] + public string? InputPayload { get; set; } +} + +/// Details for a CALLBACK operation. +public sealed class CallbackDetails +{ + /// The callback identifier returned to the external system. + [JsonPropertyName("CallbackId")] + public string? CallbackId { get; set; } + + /// Serialized callback result. + [JsonPropertyName("Result")] + public string? Result { get; set; } + + /// Error returned by the external system, if any. + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +/// Details for a CHAINED_INVOKE operation. +public sealed class ChainedInvokeDetails +{ + /// Serialized result from the invoked function. + [JsonPropertyName("Result")] + public string? Result { get; set; } + + /// Error returned by the invoked function, if any. + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +/// Details for a CONTEXT operation (child contexts). +public sealed class ContextDetails +{ + /// Serialized result of the child context. + [JsonPropertyName("Result")] + public string? Result { get; set; } + + /// Error from the child context, if any. + [JsonPropertyName("Error")] + public ErrorObject? Error { get; set; } +} + +/// +/// Wire-format string constants. +/// Plural name avoids collision with Amazon.Lambda.OperationType. +/// +public static class OperationTypes +{ + /// Step operation. + public const string Step = "STEP"; + + /// Wait/timer operation. + public const string Wait = "WAIT"; + + /// Callback (external-system signal) operation. + public const string Callback = "CALLBACK"; + + /// Chained-invoke (durable-to-durable call) operation. + public const string ChainedInvoke = "CHAINED_INVOKE"; + + /// Child-context operation. + public const string Context = "CONTEXT"; + + /// Top-level execution operation carrying the user input payload. + public const string Execution = "EXECUTION"; +} + +/// +/// Wire-format string constants. +/// Plural name avoids collision with Amazon.Lambda.OperationStatus. +/// +public static class OperationStatuses +{ + /// The operation has started. + public const string Started = "STARTED"; + + /// The operation completed successfully. + public const string Succeeded = "SUCCEEDED"; + + /// The operation failed. + public const string Failed = "FAILED"; + + /// The operation is pending (waiting for time, callback, or invocation). + public const string Pending = "PENDING"; + + /// The operation was cancelled. + public const string Cancelled = "CANCELLED"; + + /// The operation is ready to resume. + public const string Ready = "READY"; + + /// The operation was stopped. + public const string Stopped = "STOPPED"; +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs index 761391a7b..4a3f9d6a7 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs @@ -3,6 +3,10 @@ using Amazon.Runtime; using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; using SdkOperation = Amazon.Lambda.Model.Operation; +using Operation = Amazon.Lambda.DurableExecution.Operation; +using StepDetails = Amazon.Lambda.DurableExecution.StepDetails; +using WaitDetails = Amazon.Lambda.DurableExecution.WaitDetails; +using ExecutionDetails = Amazon.Lambda.DurableExecution.ExecutionDetails; namespace Amazon.Lambda.DurableExecution.Services; @@ -59,7 +63,7 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) /// SDK errors are wrapped in for the same /// reason as . /// - public async Task<(List Operations, string? NextMarker)> GetExecutionStateAsync( + public async Task<(List Operations, string? NextMarker)> GetExecutionStateAsync( string durableExecutionArn, string? checkpointToken, string marker, @@ -84,7 +88,7 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) ex); } - var operations = new List(); + var operations = new List(); if (response.Operations != null) { foreach (var sdkOp in response.Operations) @@ -96,9 +100,9 @@ public LambdaDurableServiceClient(IAmazonLambda lambdaClient) return (operations, response.NextMarker); } - private static Internal.Operation MapFromSdkOperation(SdkOperation sdkOp) + private static Operation MapFromSdkOperation(SdkOperation sdkOp) { - return new Internal.Operation + return new Operation { Id = sdkOp.Id, Type = sdkOp.Type, @@ -106,7 +110,7 @@ private static Internal.Operation MapFromSdkOperation(SdkOperation sdkOp) Name = sdkOp.Name, ParentId = sdkOp.ParentId, SubType = sdkOp.SubType, - StepDetails = sdkOp.StepDetails != null ? new Internal.StepDetails + StepDetails = sdkOp.StepDetails != null ? new StepDetails { Result = sdkOp.StepDetails.Result, Error = sdkOp.StepDetails.Error != null ? new ErrorObject @@ -119,13 +123,13 @@ private static Internal.Operation MapFromSdkOperation(SdkOperation sdkOp) ? new DateTimeOffset(sdkOp.StepDetails.NextAttemptTimestamp.Value, TimeSpan.Zero).ToUnixTimeMilliseconds() : null } : null, - WaitDetails = sdkOp.WaitDetails != null ? new Internal.WaitDetails + WaitDetails = sdkOp.WaitDetails != null ? new WaitDetails { ScheduledEndTimestamp = sdkOp.WaitDetails.ScheduledEndTimestamp.HasValue ? new DateTimeOffset(sdkOp.WaitDetails.ScheduledEndTimestamp.Value, TimeSpan.Zero).ToUnixTimeMilliseconds() : null } : null, - ExecutionDetails = sdkOp.ExecutionDetails != null ? new Internal.ExecutionDetails + ExecutionDetails = sdkOp.ExecutionDetails != null ? new ExecutionDetails { InputPayload = sdkOp.ExecutionDetails.InputPayload } : null diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs b/Libraries/src/Amazon.Lambda.DurableExecution/UpperSnakeCaseEnumConverter.cs similarity index 92% rename from Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs rename to Libraries/src/Amazon.Lambda.DurableExecution/UpperSnakeCaseEnumConverter.cs index 01d5cccf0..0f7192b22 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Internal/UpperSnakeCaseEnumConverter.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/UpperSnakeCaseEnumConverter.cs @@ -1,13 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Amazon.Lambda.DurableExecution.Internal; +namespace Amazon.Lambda.DurableExecution; /// /// Converts between UPPER_SNAKE_CASE wire format (e.g., CHAINED_INVOKE) /// and PascalCase enum values (e.g., ChainedInvoke). /// -internal sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum +public sealed class UpperSnakeCaseEnumConverter : JsonConverter where T : struct, Enum { /// public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs index f30a302de..a967c1ce2 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/DurableFunctionTests.cs @@ -7,10 +7,6 @@ using Amazon.Lambda.TestUtilities; using Amazon.Runtime; using Xunit; -using Operation = Amazon.Lambda.DurableExecution.Internal.Operation; -using StepDetails = Amazon.Lambda.DurableExecution.Internal.StepDetails; -using WaitDetails = Amazon.Lambda.DurableExecution.Internal.WaitDetails; -using ExecutionDetails = Amazon.Lambda.DurableExecution.Internal.ExecutionDetails; namespace Amazon.Lambda.DurableExecution.Tests; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs index 6500879c1..cacc68a62 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ExecutionStateTests.cs @@ -1,8 +1,7 @@ using Amazon.Lambda.DurableExecution; using Amazon.Lambda.DurableExecution.Internal; using Xunit; -using Operation = Amazon.Lambda.DurableExecution.Internal.Operation; -using StepDetails = Amazon.Lambda.DurableExecution.Internal.StepDetails; + namespace Amazon.Lambda.DurableExecution.Tests; public class ExecutionStateTests From 81380c969bd40a336a833fd708be9f5d686582de Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 16:45:39 -0400 Subject: [PATCH 15/16] update docs --- .../src/Amazon.Lambda.DurableExecution/DurableContext.cs | 6 ++++-- .../src/Amazon.Lambda.DurableExecution/DurableFunction.cs | 6 ++++-- .../src/Amazon.Lambda.DurableExecution/IDurableContext.cs | 8 +++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs index e01a26604..e79cee30b 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -69,8 +69,10 @@ private Task RunStep( var serializer = LambdaContext.Serializer ?? throw new InvalidOperationException( "No ILambdaSerializer is registered on ILambdaContext.Serializer. " + - "Register a serializer via LambdaBootstrapBuilder.Create(handler, serializer) " + - "(or in tests, set TestLambdaContext.Serializer)."); + "In the class library programming model, register one with " + + "[assembly: LambdaSerializer(typeof(...))]. In an executable / custom " + + "runtime, pass it to LambdaBootstrapBuilder.Create(handler, serializer). " + + "In tests, set TestLambdaContext.Serializer."); var operationId = _idGenerator.NextId(); var op = new StepOperation( diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index f1394c5bf..85ee73040 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -75,8 +75,10 @@ private static async Task WrapAsyncCore /// Execute a step with automatic checkpointing. The step result is serialized /// to a checkpoint using the registered on - /// (typically configured via - /// LambdaBootstrapBuilder.Create(handler, serializer)). AOT and - /// reflection-based scenarios share this single overload — the AOT story is - /// determined by the registered serializer (e.g., - /// SourceGeneratorLambdaJsonSerializer<TContext>). + /// . AOT and reflection-based scenarios + /// share this single overload — the AOT story is determined by the registered + /// serializer (e.g., SourceGeneratorLambdaJsonSerializer<TContext>). /// Task StepAsync( Func> func, From 850c901f953e7d98f3335482e9f8933d5c76f4f4 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 19 May 2026 16:52:20 -0400 Subject: [PATCH 16/16] update docs --- Docs/durable-execution-design.md | 178 ++++++++++++------------------- 1 file changed, 71 insertions(+), 107 deletions(-) diff --git a/Docs/durable-execution-design.md b/Docs/durable-execution-design.md index 6df424c5f..f639daa7a 100644 --- a/Docs/durable-execution-design.md +++ b/Docs/durable-execution-design.md @@ -251,19 +251,28 @@ private async Task MyWorkflow(OrderEvent input, IDurableContext context) } ``` -For **NativeAOT** deployments, pass a `JsonSerializerContext` so the SDK can serialize/deserialize your input and output types without reflection: +For **NativeAOT** deployments, register an AOT-aware `ILambdaSerializer` with the Lambda runtime. `WrapAsync` reads the registered serializer from `ILambdaContext.Serializer` and uses it for both envelope and step-checkpoint (de)serialization — there is no per-call `JsonSerializerContext` argument, and AOT and reflection callers share the same `WrapAsync` overloads. + +In the class library programming model, register via the assembly attribute: ```csharp +[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))] + +// The user's context must include the wire-envelope types (the typed handler +// signature is DurableExecutionInvocationInput → DurableExecutionInvocationOutput, +// so Lambda's runtime needs to deserialize them with this serializer) plus every +// TInput/TOutput/step-result POCO the workflow uses. +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] -internal partial class MyJsonContext : JsonSerializerContext { } +public partial class MyJsonContext : JsonSerializerContext { } public class Function { public Task FunctionHandler( DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync( - MyWorkflow, invocationInput, context, MyJsonContext.Default); + => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); private async Task MyWorkflow(OrderEvent input, IDurableContext context) { @@ -272,6 +281,8 @@ public class Function } ``` +In an executable / custom-runtime deployment, pass the serializer to `LambdaBootstrapBuilder.Create(handler, serializer)` instead of using the assembly attribute — `RuntimeSupport` will propagate it onto `ILambdaContext.Serializer` for the SDK to pick up. + To inject a custom `IAmazonLambda` client (e.g., for VPC endpoints or unit testing), use the overload that accepts one: ```csharp @@ -940,18 +951,18 @@ Static helper for the non-Annotations handler path. Wraps a workflow function, h /// /// Static helper that wraps a durable workflow function, handling all envelope /// translation between DurableExecutionInvocationInput/Output and user types. -/// Inspired by OpenTelemetry.Instrumentation.AWSLambda's AWSLambdaWrapper.TraceAsync pattern. +/// +/// All four overloads dispatch through the ILambdaSerializer registered on +/// ILambdaContext.Serializer, so AOT-safe and reflection-based callers share a +/// single code path. Callers wire AOT support by registering an AOT-aware +/// serializer with the runtime (e.g., SourceGeneratorLambdaJsonSerializer<TContext>) +/// — there is no per-call JsonSerializerContext argument. /// public static class DurableFunction { - // ── Reflection-based overloads (JIT only) ────────────────────────── - /// - /// Wrap a workflow that takes typed input and returns typed output. - /// Reflection-based JSON — not AOT-safe. + /// Wrap a workflow (typed input + output). /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, @@ -959,10 +970,7 @@ public static class DurableFunction /// /// Wrap a workflow (typed input + output) with explicit Lambda client. - /// Reflection-based JSON — not AOT-safe. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, @@ -971,10 +979,7 @@ public static class DurableFunction /// /// Wrap a void workflow (typed input, no output). - /// Reflection-based JSON — not AOT-safe. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func workflow, DurableExecutionInvocationInput invocationInput, @@ -982,60 +987,17 @@ public static class DurableFunction /// /// Wrap a void workflow with explicit Lambda client. - /// Reflection-based JSON — not AOT-safe. /// - [RequiresUnreferencedCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] - [RequiresDynamicCode("Uses reflection-based JSON. Use the JsonSerializerContext overload for AOT.")] public static Task WrapAsync( Func workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, IAmazonLambda lambdaClient); - - // ── AOT-safe overloads (caller supplies JsonSerializerContext) ────── - - /// - /// Wrap a workflow (typed input + output). AOT-safe — requires - /// [JsonSerializable(typeof(TInput))] and [JsonSerializable(typeof(TOutput))] - /// on the supplied jsonContext. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext); - - /// - /// Wrap a workflow (typed input + output) with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func> workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext); - - /// - /// Wrap a void workflow (typed input, no output). AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - JsonSerializerContext jsonContext); - - /// - /// Wrap a void workflow with explicit Lambda client. AOT-safe. - /// - public static Task WrapAsync( - Func workflow, - DurableExecutionInvocationInput invocationInput, - ILambdaContext lambdaContext, - IAmazonLambda lambdaClient, - JsonSerializerContext jsonContext); } ``` +`WrapAsync` requires an `ILambdaSerializer` on `ILambdaContext.Serializer`. If none is registered the helper throws `InvalidOperationException` with a message that points at the three places to register one (assembly attribute, `LambdaBootstrapBuilder.Create`, or `TestLambdaContext.Serializer` for tests). + ### IDurableContext > **Implementations:** [Python](https://github.com/aws/aws-durable-execution-sdk-python/blob/main/src/aws_durable_execution_sdk_python/context.py) | [JavaScript](https://github.com/aws/aws-durable-execution-sdk-js/blob/main/packages/aws-durable-execution-sdk-js/src/types/durable-context.ts) @@ -1064,14 +1026,15 @@ public interface IDurableContext // The user's function always receives IStepContext, matching the // Python and JS SDKs (Java has no-context overloads but deprecated // them — see https://github.com/aws/aws-durable-execution-sdk-java). + // Step results are serialized via the ILambdaSerializer registered on + // ILambdaContext.Serializer. AOT and reflection callers share one + // overload — the AOT story is determined by the registered serializer. /// - /// Execute a step with automatic checkpointing using reflection-based JSON. - /// The IStepContext provides a step-scoped logger with operation metadata - /// (step name, attempt number, operation ID) and the current attempt number. + /// Execute a step with automatic checkpointing. The IStepContext provides + /// a step-scoped logger with operation metadata (step name, attempt number, + /// operation ID) and the current attempt number. /// - [RequiresUnreferencedCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] - [RequiresDynamicCode("Reflection-based JSON for T. Use the ICheckpointSerializer overload for AOT/trimmed deployments.")] Task StepAsync( Func> func, string? name = null, @@ -1079,7 +1042,7 @@ public interface IDurableContext CancellationToken cancellationToken = default); /// - /// Execute a step that returns no value. AOT-safe (no payload to serialize). + /// Execute a step that returns no value. /// Task StepAsync( Func func, @@ -1087,17 +1050,6 @@ public interface IDurableContext StepConfig? config = null, CancellationToken cancellationToken = default); - /// - /// Execute a step with AOT-safe checkpoint serialization. The supplied - /// serializer is used in place of reflection-based JSON. - /// - Task StepAsync( - Func> func, - ICheckpointSerializer serializer, - string? name = null, - StepConfig? config = null, - CancellationToken cancellationToken = default); - /// /// Suspend execution for the specified duration. /// Throws ArgumentOutOfRangeException if duration is less than 1 second. @@ -1244,11 +1196,11 @@ public class StepConfig /// public StepSemantics Semantics { get; set; } = StepSemantics.AtLeastOncePerRetry; - // Note: there is no Serializer property here. Custom serializers are - // supplied via the AOT-safe StepAsync(..., ICheckpointSerializer, ...) - // overload, which is type-safe (ICheckpointSerializer instead of the - // non-generic marker) and gives one obvious way to opt into custom or - // AOT-friendly serialization. + // Note: there is no Serializer property here. Step result serialization + // is delegated to the ILambdaSerializer registered on ILambdaContext.Serializer + // (assembly attribute or LambdaBootstrapBuilder.Create). To swap the + // step-checkpoint format for a single step, the planned route is the + // StepAsync(..., ICheckpointSerializer, ...) overload (post-v1). } public enum StepSemantics @@ -1676,11 +1628,11 @@ public interface ICheckpointSerializer public record SerializationContext(string OperationId, string DurableExecutionArn); ``` -Usage — pass the serializer to the AOT-safe `StepAsync` overload directly. -This is the only way to override the default reflection-based JSON path; it's -intentional that there's no `StepConfig.Serializer` knob, so you have one -obvious place to opt in (and the type is `ICheckpointSerializer`, not the -non-generic marker, so the compiler catches a mismatched `T`): +Usage — pass the serializer to the per-step `StepAsync` overload directly. This is +the only way to override the registered `ILambdaSerializer` for a single step's +checkpoint; it's intentional that there's no `StepConfig.Serializer` knob, so you +have one obvious place to opt in (and the type is `ICheckpointSerializer`, not +a non-generic marker, so the compiler catches a mismatched `T`): ```csharp var result = await context.StepAsync( @@ -1689,6 +1641,8 @@ var result = await context.StepAsync( name: "get_data"); ``` +> **Status:** the `ICheckpointSerializer` overload is a planned post-v1 addition. Today, all step checkpoints flow through the `ILambdaSerializer` registered on `ILambdaContext.Serializer` — see [NativeAOT compatibility](#nativeaot-compatibility) for how that's wired. + ### Class library vs. executable output All samples in this doc use the class library pattern (no `Main` method). This is the default for Lambda functions. To turn a durable function project into an executable (required for NativeAOT or custom runtimes): @@ -1713,36 +1667,46 @@ Both approaches produce a self-contained executable that the Lambda custom runti ### NativeAOT compatibility -The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. For NativeAOT deployments, AOT safety is addressed at two levels — **at each level there are two overload families: a reflection-based one annotated with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` and an AOT-safe one that requires a serializer parameter**. The trimmer warns at the call site when reflection overloads are used in AOT/trimmed builds. +The SDK is AOT-friendly but does not require AOT. The default JSON serialization uses reflection (standard `System.Text.Json` behavior), which works in JIT mode. **AOT safety is determined entirely by which `ILambdaSerializer` the user registers with the Lambda runtime** — there is no separate AOT-only API surface in the SDK, and no per-call `JsonSerializerContext` argument anywhere on `WrapAsync` or `IDurableContext`. The same overloads work in JIT and AOT; the difference is whether `ILambdaContext.Serializer` resolves to `DefaultLambdaJsonSerializer` (reflection) or `SourceGeneratorLambdaJsonSerializer` (AOT). + +The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. -1. **Entry point (`DurableFunction.WrapAsync`)** — the AOT-safe overload takes a `JsonSerializerContext` parameter that includes type info for your `TInput` and `TOutput` types. +#### What the user registers in their `JsonSerializerContext` -2. **Step checkpoints (`IDurableContext.StepAsync`)** — the AOT-safe overload takes an `ICheckpointSerializer` directly as a parameter. Internally, the reflection overload constructs `ReflectionJsonCheckpointSerializer` (whose constructor carries `[RequiresUnreferencedCode]`); the AOT-safe overload uses the user-supplied serializer and never touches reflection. The void `StepAsync` overloads are AOT-safe by default — they use a built-in null-only serializer since they have no payload. +For AOT, the user's source-generated context must include: -The SDK itself avoids `Activator.CreateInstance`, `Type.GetType()`, and other reflection patterns, and uses `[DynamicallyAccessedMembers]` trimming annotations where needed. +1. **Wire-envelope types** — `DurableExecutionInvocationInput` and `DurableExecutionInvocationOutput`. The handler signature is typed against these, so Lambda's runtime calls `serializer.Deserialize(...)` on each invoke and the source generator needs `JsonTypeInfo` for both. +2. **Workflow input / output POCOs** — every `TInput` / `TOutput` that appears in a `WrapAsync` call. +3. **Step result types** — every `T` that appears in `context.StepAsync(...)`. The SDK serializes step results via the same `ILambdaSerializer`, so each result type needs source-gen registration too. ```csharp -// Default: works with reflection (JIT mode); flagged for AOT. -var result = await context.StepAsync(async (step) => await GetOrder()); +// Class library mode — register via the assembly attribute. +[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))] -// AOT mode — entry point: pass JsonSerializerContext to WrapAsync. +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] [JsonSerializable(typeof(OrderEvent))] [JsonSerializable(typeof(OrderResult))] -[JsonSerializable(typeof(Order))] -internal partial class MyJsonContext : JsonSerializerContext { } +[JsonSerializable(typeof(Order))] // step result +public partial class MyJsonContext : JsonSerializerContext { } -public Task FunctionHandler( - DurableExecutionInvocationInput invocationInput, ILambdaContext context) - => DurableFunction.WrapAsync( - MyWorkflow, invocationInput, context, MyJsonContext.Default); +public class Function +{ + public Task FunctionHandler( + DurableExecutionInvocationInput invocationInput, ILambdaContext context) + => DurableFunction.WrapAsync(MyWorkflow, invocationInput, context); -// AOT mode — step checkpoint: pass ICheckpointSerializer to StepAsync directly. -var result = await context.StepAsync( - async () => await GetOrder(), - new JsonCheckpointSerializer(MyJsonContext.Default.Order), - name: "get_order"); + private async Task MyWorkflow(OrderEvent input, IDurableContext context) + { + // Same StepAsync overload in JIT and AOT — the registered serializer decides. + var order = await context.StepAsync(async (step) => await GetOrder(), name: "get_order"); + // ... + } +} ``` +For executable / custom-runtime deployments (no class library attribute), the same context is registered by passing the serializer to `LambdaBootstrapBuilder.Create(handler, serializer)` — see the [Manual Handler](#manual-handler-without-annotations) section. + ### Large payload and checkpoint overflow The durable execution service imposes size limits: