|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## What this is |
| 6 | + |
| 7 | +`Amazon.Lambda.DurableExecution` is the .NET SDK (preview, 0.x) for resilient, long-running AWS Lambda |
| 8 | +workflows that checkpoint progress after each step and resume after failures or waits. A workflow can run |
| 9 | +for up to ~1 year (the WAIT cap is 31,622,400 seconds) and is only billed for active compute. The SDK is |
| 10 | +client-side glue: the *durable execution service* (part of Lambda) owns the checkpoint store, fires timers, |
| 11 | +and re-invokes the function; this library re-derives in-memory workflow position from the checkpoint history |
| 12 | +the service sends on each invocation. See sibling SDKs (Python/JS/Java) listed in `README.md` for the shared |
| 13 | +model — this SDK deliberately mirrors their semantics. |
| 14 | + |
| 15 | +## Build & test |
| 16 | + |
| 17 | +Targets `net8.0;net10.0` (`DefaultPackageTargets` in `buildtools/common.props`). `TreatWarningsAsErrors` is on |
| 18 | +everywhere, and the main library is `IsTrimmable` with the trim analyzer enabled — keep new code AOT/trim-clean. |
| 19 | + |
| 20 | +```bash |
| 21 | +# Build the library (run from this directory) |
| 22 | +dotnet build |
| 23 | + |
| 24 | +# Unit tests (fast, no AWS). Project: Libraries/test/Amazon.Lambda.DurableExecution.Tests |
| 25 | +dotnet test ../../test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj |
| 26 | + |
| 27 | +# A single test |
| 28 | +dotnet test ../../test/Amazon.Lambda.DurableExecution.Tests/Amazon.Lambda.DurableExecution.Tests.csproj \ |
| 29 | + --filter "FullyQualifiedName~StepOperationTests" |
| 30 | + |
| 31 | +# Coverage report (requires reportgenerator tool) |
| 32 | +../../test/Amazon.Lambda.DurableExecution.Tests/coverage.sh |
| 33 | +``` |
| 34 | + |
| 35 | +Unit tests reach `internal` types via `InternalsVisibleTo` (declared in the `.csproj`). They use |
| 36 | +`Amazon.Lambda.TestUtilities` (`TestLambdaContext`) and the real `SourceGeneratorLambdaJsonSerializer` — |
| 37 | +set `TestLambdaContext.Serializer` so `LambdaSerializerHelper.GetRequired` finds one. |
| 38 | + |
| 39 | +### Integration tests (expensive, real AWS) |
| 40 | + |
| 41 | +`Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests` deploys real Lambdas. Each test builds a |
| 42 | +`TestFunctions/<X>/` project into a container image via **`dotnet publish` + `docker build`**, pushes to ECR, |
| 43 | +creates an IAM role + Lambda (`DurableFunctionDeployment`), invokes it, and tears everything down on dispose. |
| 44 | +Requires Docker, AWS creds (us-east-1), and is slow. Every behavior in `docs/` should have a paired |
| 45 | +integration test under that project. Prefix AWS commands with `unset AWS_PROFILE` to use `[default]` creds. |
| 46 | + |
| 47 | +**Run integration tests against `net10.0`.** The project multi-targets `net8.0;net10.0`; `dotnet test` |
| 48 | +without a framework spins up one testhost per TFW and runs them concurrently, which races two processes on |
| 49 | +the same `TestFunctions/<X>/` build dir. Pin the framework: |
| 50 | + |
| 51 | +```bash |
| 52 | +dotnet test ../../test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj \ |
| 53 | + -f net10.0 --filter "FullyQualifiedName~MultipleStepsTest" |
| 54 | +``` |
| 55 | + |
| 56 | +## Architecture: the replay model |
| 57 | + |
| 58 | +This is the part you must understand before changing anything. Read these together: |
| 59 | +`DurableFunction.cs`, `DurableExecutionHandler.cs`, `DurableContext.cs`, `Internal/DurableOperation.cs`, |
| 60 | +`Internal/ExecutionState.cs`, `Internal/OperationIdGenerator.cs`, `Internal/TerminationManager.cs`. |
| 61 | + |
| 62 | +**Entry point.** The user's Lambda handler delegates to `DurableFunction.WrapAsync<TInput,TOutput>`, which: |
| 63 | +hydrates `ExecutionState` from `invocationInput.InitialExecutionState` (paging the service via `NextMarker`), |
| 64 | +extracts the user payload from the `EXECUTION`-type op, builds a `CheckpointBatcher` + `DurableContext`, runs |
| 65 | +the workflow through `DurableExecutionHandler.RunAsync`, drains checkpoints, and maps the result to a |
| 66 | +`DurableExecutionInvocationOutput` with status **Succeeded / Failed / Pending**. |
| 67 | + |
| 68 | +**Each operation runs the same workflow code every invocation.** There is no persisted program counter. |
| 69 | +On re-invocation the user function executes from the top again; each durable call (`StepAsync`, `WaitAsync`, |
| 70 | +etc.) looks up its own checkpoint and either replays the cached result or runs fresh. This is why workflow |
| 71 | +code **must be deterministic** — same operations, same order, same names across deployments. |
| 72 | + |
| 73 | +**Deterministic operation IDs** (`OperationIdGenerator`). Each durable call gets an ID = SHA-256 of |
| 74 | +`"<parentPrefix>-<counter>"`, where the counter is per-context and pre-incremented. The same workflow position |
| 75 | +yields the same opaque ID across replays, so a checkpoint correlates to a call by *position*, not by name — |
| 76 | +renaming a step does **not** break replay (the human name rides separately on `OperationUpdate.Name`). |
| 77 | +Reordering or adding/removing calls *does* break it. `ValidateReplayConsistency` enforces this and throws |
| 78 | +`NonDeterministicExecutionException` on type/name drift. |
| 79 | + |
| 80 | +**Suspension is implemented by never completing a Task** (`TerminationManager` + `DurableExecutionHandler`). |
| 81 | +When an op must suspend (wait timer, scheduled retry, pending callback/invoke) it calls |
| 82 | +`Termination.SuspendAndAwait<T>()`, which trips a one-shot signal and returns a Task that *never resolves*. |
| 83 | +`RunAsync` runs the user code via `Task.Run` and races it against `TerminationTask` with `Task.WhenAny`: |
| 84 | +- user task wins → **Succeeded** (or **Failed** if it threw) |
| 85 | +- termination wins → **Pending**; the abandoned user task is GC'd, checkpoints flush, the service fires the |
| 86 | + timer and re-invokes. On replay the suspended op sees its now-terminal checkpoint and returns normally. |
| 87 | + |
| 88 | +**Operation classes** (`Internal/*Operation.cs`) all extend `DurableOperation<TResult>`. The base's |
| 89 | +`ExecuteAsync` does: `ValidateReplayConsistency` → `TrackReplay` → look up checkpoint → dispatch to |
| 90 | +`StartAsync` (no prior checkpoint) or `ReplayAsync` (checkpoint exists). `StepOperation` is the canonical |
| 91 | +example — read its class doc comment for the full status decision table (Succeeded→cached, Failed→rethrow, |
| 92 | +Pending→re-suspend if retry timer hasn't fired, Started→crash-recovery under `AtMostOncePerRetry`, |
| 93 | +Ready→run next attempt). `DurableContext` is a thin dispatcher: it allocates the op ID, pulls the serializer |
| 94 | +off `ILambdaContext.Serializer`, constructs the right `*Operation`, and calls `ExecuteAsync`. |
| 95 | + |
| 96 | +**Checkpointing** (`CheckpointBatcher`). Outbound `OperationUpdate`s (START/SUCCEED/FAIL/RETRY) are enqueued |
| 97 | +to a background channel worker that batches and flushes them via `LambdaDurableServiceClient` (which wraps |
| 98 | +the `AWSSDK.Lambda` `Checkpoint`/`GetExecutionState` calls). `EnqueueAsync` awaits its batch's flush |
| 99 | +(sync semantics); fire-and-forget callers (e.g. the START checkpoint under the default |
| 100 | +`AtLeastOncePerRetry`) don't await but must observe the Task's exception. Flush errors become a terminal |
| 101 | +error rethrown by the next `EnqueueAsync`/`DrainAsync`. `DurableFunction.IsTerminalCheckpointError` |
| 102 | +classifies SDK errors on the final drain: 4xx (except 429 and stale-token) → **Failed** envelope; 429/5xx/ |
| 103 | +network → let it escape so Lambda retries the whole invocation. |
| 104 | + |
| 105 | +**Replay-mode tracking** (`ExecutionState`). `IsReplaying` starts true iff any completed non-`EXECUTION` op |
| 106 | +exists; `TrackReplay` decrements as each is visited and flips to false once the workflow catches up to the |
| 107 | +frontier. `ReplayAwareLogger` uses this to suppress log lines emitted during replay so a 30-step workflow |
| 108 | +re-invoked 30 times logs each line once — **always use `ctx.Logger`**, never `Console.WriteLine`. |
| 109 | +`ExecutionState` is lock-guarded because the batcher worker thread and concurrent parallel/map branches all |
| 110 | +touch it. |
| 111 | + |
| 112 | +### Operations surface (`IDurableContext`) |
| 113 | + |
| 114 | +`StepAsync` (checkpointed code + retries), `WaitAsync` (1s–~1yr timer), `RunInChildContextAsync` (isolated |
| 115 | +sub-workflow checkpointed as one `CONTEXT` op), `CreateCallbackAsync` / `WaitForCallbackAsync` (external |
| 116 | +events; `WaitForCallback` is *composed* from child-context + callback + submitter step — see |
| 117 | +`DurableContext.RunWaitForCallback`), `InvokeAsync` (durable-to-durable chained invoke, qualified ARN |
| 118 | +required), and `ParallelAsync` / `MapAsync` (concurrent branches → `IBatchResult<T>`). |
| 119 | + |
| 120 | +**Nesting (`NestingType`)** matters for parallel/map. `Nested` (default) gives each branch a full `CONTEXT` |
| 121 | +checkpoint. `Flat` runs branches in *virtual* contexts that emit no `CONTEXT` op — inner ops re-parent to the |
| 122 | +parallel/map op via `OperationIdGenerator.CreateVirtualChild(operationId, reportedParentId)`, trading trace |
| 123 | +granularity for fewer checkpoints. The `idPrefix` vs `reportedParentId` split is the subtle part: inner IDs |
| 124 | +always derive from the branch's own op ID (so siblings never collide), but are *reported* under the nearest |
| 125 | +non-virtual ancestor (so they reference a parent that actually exists in the checkpoint store). |
| 126 | + |
| 127 | +### Wire format (`Operation.cs`) |
| 128 | + |
| 129 | +`Operation` and its `*Details` types mirror the service envelope JSON exactly (`[JsonPropertyName]`). |
| 130 | +String constants live in `OperationTypes` (STEP/WAIT/CALLBACK/CHAINED_INVOKE/CONTEXT/EXECUTION), |
| 131 | +`OperationStatuses` (STARTED/SUCCEEDED/FAILED/PENDING/READY/CANCELLED/STOPPED/TIMED_OUT), and |
| 132 | +`OperationSubTypes` (PascalCase finer classifier). Plural type names (`OperationTypes`, not `OperationType`) |
| 133 | +intentionally avoid collision with `AWSSDK.Lambda` model enums. |
| 134 | + |
| 135 | +## Conventions |
| 136 | + |
| 137 | +- **Programming model:** preview supports only the *executable* model — `Main` builds a `LambdaBootstrap` |
| 138 | + with a handler wrapper and an `ILambdaSerializer`. The serializer is read off `ILambdaContext.Serializer` |
| 139 | + (a preview API; the project-wide `AWSLAMBDA001` suppression in the `.csproj` is intentional for that |
| 140 | + reason). All step/result/payload (de)serialization flows through that one registered serializer, so AOT |
| 141 | + and reflection callers share a single code path — there is no per-call `JsonSerializerContext` argument. |
| 142 | +- **Errors:** durable exceptions carry `ErrorType`/`ErrorData`/`OriginalStackTrace` so a failure can be |
| 143 | + reconstructed on replay when the live exception object is gone. `StepException`, `ChildContextException`, |
| 144 | + `CallbackFailedException`/`CallbackTimeoutException`/`CallbackSubmitterException`, `ParallelException`, |
| 145 | + `MapException`, and `NonDeterministicExecutionException` all derive from `DurableExecutionException`. |
| 146 | + When adding error-mapping logic, handle *both* the fresh path (`InnerException` is the live exception) and |
| 147 | + the replay path (`InnerException` is null, `ErrorType` carries the type string) — see |
| 148 | + `DurableContext.MapWaitForCallbackException` for the pattern. |
| 149 | +- **Public config types** (`StepConfig`, `WaitForCallbackConfig`, `ParallelConfig`, `MapConfig`, |
| 150 | + `CompletionConfig`, etc.) are nullable optional args; resolve to an effective config inside the dispatcher. |
| 151 | +- Inclusive language is enforced repo-wide (see the user's global rules): no master/slave, whitelist/blacklist. |
0 commit comments