Skip to content

feat(DurableExecution): incremental, heterogeneous Parallel API (#2519) - #2553

Draft
GarrettBeatty wants to merge 12 commits into
masterfrom
feature/heterogeneous-parallel
Draft

feat(DurableExecution): incremental, heterogeneous Parallel API (#2519)#2553
GarrettBeatty wants to merge 12 commits into
masterfrom
feature/heterogeneous-parallel

Conversation

@GarrettBeatty

Copy link
Copy Markdown
Contributor

Description

Implements #2519: an additive, branch-oriented parallel API for Amazon.Lambda.DurableExecution supporting heterogeneous per-branch result types and incremental branch registration, alongside the existing homogeneous ParallelAsync<T> overloads (which are unchanged).

await using var parallel = ctx.CreateParallel(name: "process-order");

IParallelBranch<InventoryReservation> inventory = parallel.BranchAsync(
    "inventory", async (branch, ct) => await ReserveInventoryAsync(branch, ct));
IParallelBranch<PaymentAuthorization> payment = parallel.BranchAsync(
    "payment", async (branch, ct) => await AuthorizePaymentAsync(branch, ct));

IBatchResult summary = await parallel.CompleteAsync();

InventoryReservation reserved = await inventory;   // own concrete type — no shared base, cast, or envelope
PaymentAuthorization  authed   = await payment;

What & why

Today every branch of a Parallel must share one generic result type T, forcing unrelated branch contracts into object, a common base type, or a wrapper. This adds a branch-scoped generic API (matching the Java SDK's ParallelDurableFuture) that gives each branch its own compile-time type, replay-safe per-branch deserialization, and incremental composition (register/start branches as work is discovered, then seal).

New public API

  • IDurableContext.CreateParallel(name?, config?)IDurableParallel
  • IDurableParallel : IAsyncDisposableBranchAsync<T>(name, func), CompleteAsync(ct)
  • IParallelBranch<T> — awaitable typed handle exposing Name / Index / Status

Design

  • Each branch runs as an existing ChildContextOperation<T> with the same deterministic child op id (hash("{parentId}-{index}")) and the same parent CONTEXT/Parallel BatchSummary checkpoint shape as batch Parallel — so replay, checkpoints, and reconstruction are identical and interoperable.
  • Branches start on registration, gated by a shared MaxConcurrency semaphore and a cooperative short-circuit token; CompleteAsync seals, awaits per CompletionConfig, and checkpoints the aggregate.
  • Deterministic replay: branch identity is positional (register the same branches in the same order); a name change at an index throws NonDeterministicExecutionException. Terminal-parent replay reconstructs from the frozen inline summary without re-running (re-running only overflow-stripped branches).
  • DisposeAsync auto-completes if CompleteAsync wasn't called, so await using always writes the terminal checkpoint.
  • MaxConcurrency, CompletionConfig, NestingType, cancellation, and the registered ILambdaSerializer are honored unchanged.
  • Refactors BatchSummary (de)serialization + overflow handling out of ConcurrentOperation<T> into a shared BatchSummaryCodec so the batch and incremental paths can't diverge on the wire format.

Testing

  • 16 unit tests (IncrementalParallelOperationTests): fresh happy path, heterogeneous types, deterministic ids, MaxConcurrency, completion short-circuit/skip, failure surfacing, empty, replay reconstruct (inline + failed branch), name-drift, and STARTED-parent replay. All 428 unit tests pass.
  • 2 integration tests, both verified green against the durable-execution service:
    • IncrementalParallelHeterogeneousTest — string/int/POCO branches round-trip end-to-end.
    • IncrementalParallelReplayTest — deterministic replay across both the STARTED-parent Run path and the terminal-reconstruct resume (each branch step executes exactly once).
  • Docs: new "Incremental, heterogeneous branches" section in docs/core/parallel.md.

Note for reviewers

Because IParallelBranch<T> is awaitable, a bare parallel.BranchAsync(...) statement whose result is ignored trips CS4014 under the repo's warnings-as-errors — callers must capture or discard (_ =) the handle. Flagging in case the team prefers a non-awaitable handle + explicit GetResultAsync().

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Replay validation, cancellation, completion concurrency, and branch result consistency contain unresolved correctness issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds an incremental parallel API supporting heterogeneous typed branches while preserving existing batch APIs and checkpoint formats.

Changes:

  • Adds CreateParallel, typed branch handles, and orchestration logic.
  • Extracts shared batch-summary serialization.
  • Adds unit, integration, and documentation coverage.
File summaries
File Description
IncrementalParallelOperationTests.cs Tests incremental execution and replay.
IncrementalParallelReplayFunction.csproj Configures replay test function.
IncrementalParallelReplayFunction/Function.cs Exercises replay paths.
IncrementalParallelHeterogeneousFunction.csproj Configures heterogeneous test function.
IncrementalParallelHeterogeneousFunction/Function.cs Exercises typed branches.
IncrementalParallelReplayTest.cs Validates replay integration.
IncrementalParallelHeterogeneousTest.cs Validates heterogeneous integration.
IParallelBranch.cs Defines typed awaitable handles.
IncrementalParallelOperation.cs Implements incremental orchestration.
ConcurrentOperation.cs Uses shared summary codec.
BatchSummaryCodec.cs Centralizes summary serialization.
IDurableParallel.cs Defines the public parallel API.
IDurableContext.cs Exposes CreateParallel.
DurableContext.cs Constructs incremental operations.
docs/core/parallel.md Documents the new API.
Review details

Suppressed comments (2)

Libraries/src/Amazon.Lambda.DurableExecution/Internal/IncrementalParallelOperation.cs:160

  • Serialize the value before completing the public result task. With NestingType.Flat, Serialize(value) can throw after TrySetResult; the catch path then records a failed outcome but cannot replace the already-successful handle result, so summary.HasFailure/Status report failure while await branch returns a value.
            var value = await run().ConfigureAwait(false);
            if (_frozenStatus is null) _status = (int)BatchItemStatus.Succeeded;
            _result.TrySetResult(value);
            return BranchOutcome.Success(Index, Name, Serialize(value));

Libraries/src/Amazon.Lambda.DurableExecution/Internal/IncrementalParallelOperation.cs:331

  • Handle missing/corrupt terminal summaries explicitly. ParseSummary returns null for these payloads, but the operation remains in Terminal mode; every branch is then resolved as skipped and CompleteAsync synthesizes AllCompleted, masking a previously terminal checkpoint. Recover from child checkpoints where possible or fail replay rather than returning a false success.
        if (terminal)
        {
            _mode = ParallelExecutionMode.Terminal;
            _frozenSummary = BatchSummaryCodec.ParseSummary(existing!.ContextDetails?.Result);
            _startTask = Task.CompletedTask;
  • Files reviewed: 16/16 changed files
  • Comments generated: 7
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Libraries/src/Amazon.Lambda.DurableExecution/IDurableContext.cs
@GarrettBeatty
GarrettBeatty changed the base branch from master to feature/per-step-serializer-conformance September 2, 2026 21:09
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 43ff3ef to 3fd7096 Compare September 2, 2026 21:10
@GarrettBeatty
GarrettBeatty requested a balanced review from Copilot September 2, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.

@GarrettBeatty
GarrettBeatty force-pushed the feature/per-step-serializer-conformance branch 2 times, most recently from cfac883 to 545df80 Compare September 3, 2026 03:23
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 3fd7096 to 9b1220e Compare September 3, 2026 03:35
GarrettBeatty added a commit that referenced this pull request Sep 3, 2026
…al-parallel overflow/await fixes, serializer deferral, Branch rename, docs

DurableExecution PR #2553 (stacked on feature/per-step-serializer-conformance).

1. [BLOCKER] StepOperation.ExecuteFunc: move the fresh-success SUCCEED enqueue
   and result round-trip OUTSIDE the try that funnels into HandleStepFailureAsync
   (mirrors ChildContextOperation). A serializer that cannot deserialize its own
   just-written payload now surfaces the fault directly instead of enqueuing a
   RETRY/FAIL that conflicts with the already-committed SUCCEED.

2. [MAJOR] .autover/changes/12a4a1f7: Minor -> Major. The package is GA (1.x) per
   CLAUDE.md, and the unconditional fresh-success round-trip is an observable
   happy-path behavior change for ALL serializers on non-suspending workflows
   (reference identity, DateTime.Kind, [JsonIgnore], precision). Not preview-exempt.

3. [MAJOR] IncrementalParallelOperation overflow recovery: isolate overflow-recovery
   re-runs (frozenStatus set) from _shortCircuitCts/_dispatchCts so a completion-policy
   short-circuit can no longer cancel them; and exclude frozen branches from the
   cooperative-bail arm so _result honors _frozenStatus (never resolves a frozen
   Succeeded branch to SkippedError, which made `await branch` throw while
   Status==Succeeded and lost the recovered value).

4. [MINOR] DurableContext.CreateParallel: defer LambdaSerializerHelper.GetRequired via
   a lazy factory (memoized in the operation). A workflow overriding the serializer on
   every branch no longer requires a global serializer at CreateParallel time (AOT /
   per-branch scenario). GetRequired is resolved only when a branch falls back.

5. [MINOR] Rename IDurableParallel.BranchAsync<T> -> Branch<T>. The method returns a
   handle synchronously (not a Task), so the Async suffix was misleading. Safe: the
   API is new/unreleased (absent on master). Updated the interface, impl, all call
   sites (conformance + tests), docs (parallel.md), and the AutoVer changelog text.

6. [MINOR] IDurableParallel.Branch XML doc: document ArgumentNullException,
   ObjectDisposedException, and NonDeterministicExecutionException in addition to
   InvalidOperationException.

7. [MINOR] IncrementalParallelBranch.ExecuteAsync: fault _result before rethrowing a
   workflow-level DurableExecutionException, so a caller that catches the fault out of
   CompleteAsync and then awaits the handle observes the fault instead of hanging.

8. [MINOR] Correct the IncrementalParallelOperation class summary and IParallelBranch.Index
   doc to reflect the 1-based operation-ID suffix (hash("{parentId}-{index+1}")).

9. [NIT] IncrementalParallelHeterogeneousTest: replace the tautological Contains("200")
   (satisfied by the "USD:4200" POCO branch) with the distinguishing token "Payment":200.

Tests: added 4 unit tests (fresh-success round-trip deserialize failure surfaces
without RETRY/FAIL; CreateParallel with no global serializer + per-branch overrides
does not throw; deferred fallback still throws on a non-overriding branch; a branch
faulting with a workflow-level error faults the handle instead of hanging). Build and
Amazon.Lambda.DurableExecution.Tests pass (447/447, net10.0). Integration-test and
deployed-function projects compile; the heterogeneous integration test requires an AWS
deployment and was not run here.

AutoVer: source changes are refinements to the two features already covered by the
existing change files, so both existing entries were updated (12a4a1f7 -> Major;
add-incremental changelog text updated for the Branch rename) rather than adding a new
change file. Reclassifying 12a4a1f7's Type was a one-field edit — the AutoVer CLI has
no edit verb, and adding a third Major entry would have left the mislabeled Minor in place.
GarrettBeatty added a commit that referenced this pull request Sep 3, 2026
…al-parallel overflow/await fixes, serializer deferral, Branch rename, docs

DurableExecution PR #2553 (stacked on feature/per-step-serializer-conformance).

1. [BLOCKER] StepOperation.ExecuteFunc: move the fresh-success SUCCEED enqueue
   and result round-trip OUTSIDE the try that funnels into HandleStepFailureAsync
   (mirrors ChildContextOperation). A serializer that cannot deserialize its own
   just-written payload now surfaces the fault directly instead of enqueuing a
   RETRY/FAIL that conflicts with the already-committed SUCCEED.

2. [MAJOR] .autover/changes/12a4a1f7: Minor -> Major. The package is GA (1.x) per
   CLAUDE.md, and the unconditional fresh-success round-trip is an observable
   happy-path behavior change for ALL serializers on non-suspending workflows
   (reference identity, DateTime.Kind, [JsonIgnore], precision). Not preview-exempt.

3. [MAJOR] IncrementalParallelOperation overflow recovery: isolate overflow-recovery
   re-runs (frozenStatus set) from _shortCircuitCts/_dispatchCts so a completion-policy
   short-circuit can no longer cancel them; and exclude frozen branches from the
   cooperative-bail arm so _result honors _frozenStatus (never resolves a frozen
   Succeeded branch to SkippedError, which made `await branch` throw while
   Status==Succeeded and lost the recovered value).

4. [MINOR] DurableContext.CreateParallel: defer LambdaSerializerHelper.GetRequired via
   a lazy factory (memoized in the operation). A workflow overriding the serializer on
   every branch no longer requires a global serializer at CreateParallel time (AOT /
   per-branch scenario). GetRequired is resolved only when a branch falls back.

5. [MINOR] Rename IDurableParallel.BranchAsync<T> -> Branch<T>. The method returns a
   handle synchronously (not a Task), so the Async suffix was misleading. Safe: the
   API is new/unreleased (absent on master). Updated the interface, impl, all call
   sites (conformance + tests), docs (parallel.md), and the AutoVer changelog text.

6. [MINOR] IDurableParallel.Branch XML doc: document ArgumentNullException,
   ObjectDisposedException, and NonDeterministicExecutionException in addition to
   InvalidOperationException.

7. [MINOR] IncrementalParallelBranch.ExecuteAsync: fault _result before rethrowing a
   workflow-level DurableExecutionException, so a caller that catches the fault out of
   CompleteAsync and then awaits the handle observes the fault instead of hanging.

8. [MINOR] Correct the IncrementalParallelOperation class summary and IParallelBranch.Index
   doc to reflect the 1-based operation-ID suffix (hash("{parentId}-{index+1}")).

9. [NIT] IncrementalParallelHeterogeneousTest: replace the tautological Contains("200")
   (satisfied by the "USD:4200" POCO branch) with the distinguishing token "Payment":200.

Tests: added 4 unit tests (fresh-success round-trip deserialize failure surfaces
without RETRY/FAIL; CreateParallel with no global serializer + per-branch overrides
does not throw; deferred fallback still throws on a non-overriding branch; a branch
faulting with a workflow-level error faults the handle instead of hanging). Build and
Amazon.Lambda.DurableExecution.Tests pass (447/447, net10.0). Integration-test and
deployed-function projects compile; the heterogeneous integration test requires an AWS
deployment and was not run here.

AutoVer: source changes are refinements to the two features already covered by the
existing change files, so both existing entries were updated (12a4a1f7 -> Major;
add-incremental changelog text updated for the Branch rename) rather than adding a new
change file. Reclassifying 12a4a1f7's Type was a one-field edit — the AutoVer CLI has
no edit verb, and adding a third Major entry would have left the mislabeled Minor in place.
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 9b727b7 to c760c01 Compare September 3, 2026 03:51
@GarrettBeatty
GarrettBeatty changed the base branch from feature/per-step-serializer-conformance to feature/per-step-serializer September 3, 2026 03:51
GarrettBeatty added a commit that referenced this pull request Sep 3, 2026
… fix branch name-drift message

Address Copilot review on #2553:
- CreateParallel 'name' XML docs said a name change 'does not break replay',
  but the name is passed to ValidateReplayConsistency (throws on drift). Doc now
  states the name is part of the deterministic definition and must stay stable.
- Branch name-drift NonDeterministicExecutionException message had expected/found
  inverted; now reports the checkpointed name as expected and the current
  registration as the drifted value.
GarrettBeatty added a commit that referenced this pull request Sep 3, 2026
… fix branch name-drift message

Address Copilot review on #2553:
- CreateParallel 'name' XML docs said a name change 'does not break replay',
  but the name is passed to ValidateReplayConsistency (throws on drift). Doc now
  states the name is part of the deterministic definition and must stay stable.
- Branch name-drift NonDeterministicExecutionException message had expected/found
  inverted; now reports the checkpointed name as expected and the current
  registration as the drifted value.
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 51d7258 to b244c21 Compare September 3, 2026 18:02
* feat(DurableExecution): add per-operation serializer override

Add an optional `ILambdaSerializer? Serializer` to StepConfig, CallbackConfig,
InvokeConfig, WaitForConditionConfig<TState>, and ChildContextConfig. DurableContext
resolves `config?.Serializer ?? ILambdaContext.Serializer` per operation; the operation
classes are otherwise unchanged. When Serializer is null (default), behavior is identical
to today (the globally-registered serializer). Boundary serialization (workflow input,
handler return, service envelopes) is untouched.

Reuses the existing ILambdaSerializer contract (no new SerDes interface); AOT-safe because
T stays concrete at each call site. Additive / non-breaking.

- Unit tests (PerOperationSerializerTests) covering per-op serialize, default fallback,
  and replay-deserialize for Step/Callback/Invoke/ChildContext/WaitForCondition.
- Integration test (TestFunctions/PerOperationSerializerFunction + PerOperationSerializerTest)
  verifying per-step routing end-to-end (camelCase override vs PascalCase default).
- docs/core updates and an AutoVer change file (Minor).

Separate feature from #2540 (FileSystem offload). Phase 1 (single-result ops); Map/Parallel
two-level slots are a follow-up (Phase 2).

* feat(DurableExecution): add per-item serializer for Map/Parallel

Add an optional `ILambdaSerializer? ItemSerializer` to MapConfig<TItem> and ParallelConfig.
DurableContext.RunMap/RunParallel resolve `config.ItemSerializer ?? ILambdaContext.Serializer`
and pass it as the serializer used for per-item/branch results (each unit's child-context
checkpoint and the inline copy on the operation's summary). The aggregated batch envelope
(per-unit statuses + completion reason) is a source-generated structure and is unchanged —
there is no separate whole-result serializer (matches the Java SDK). Additive / non-breaking.

- Unit tests: Map_WithItemSerializer, Map_NoItemSerializer, Parallel_WithItemSerializer.
- docs/core (parallel.md, steps.md) + AutoVer change file.

Phase 2 of the per-operation serializer feature (Phase 1 = single-result ops).

* test(DurableExecution): cloud integration test for Map/Parallel ItemSerializer

Deploys MapParallelItemSerializerFunction and asserts from event history that
map items and a parallel branch configured with a camelCase ItemSerializer
produce camelCase per-item child-context result payloads, while a control step
(global serializer) stays PascalCase. Validates Phase 2 end-to-end on AWS.

* address review: consistent serializer resolution, config headers, serializer unit coverage

- DurableContext: Parallel now resolves the effective serializer via
  LambdaSerializerHelper.GetRequired(LambdaContext) like every other operation,
  instead of an inline 'LambdaContext.Serializer ?? throw' (consistent error path).
- Add the standard copyright/SPDX header to MapConfig.cs and WaitForConditionConfig.cs.
- Add unit tests: Invoke fresh (non-replay) path serializes the request payload via
  the per-op serializer; Map/Parallel ItemSerializer is used to deserialize cached
  per-item/per-branch results on replay.

* test(DurableExecution): per-op serdes conformance + fresh-success serializer round-trip (#2556)

* test(DurableExecution): conformance handlers for per-item/result serdes (map 9-14, parallel 8-15, invoke 5-16)

Add conformance handlers exercising the new per-operation serializer slots:
- map/MapCustomSerdes (9-14): MapConfig.ItemSerializer wraps each item result
- parallel/ParallelCustomSerdes (8-15): ParallelConfig.ItemSerializer wraps each branch result
- invoke/InvokeCustomResultSerdes (5-16): InvokeConfig.Serializer uppercases the result on deserialize
Remove the corresponding NotImplemented declarations and wire the resources in
template_map/parallel/invoke.yaml. All three pass against real AWS via the runner.

Packaging fix: add Conformance/Directory.Build.props supplying the SDK's runtime
NuGet dependencies (AWSSDK.Lambda, Microsoft.Extensions.Logging.Abstractions) to
every handler, since those transitive package assets do not flow into a handler's
net8.0 framework-dependent publish (WrapAsyncCore threw FileNotFoundException at
runtime). Drop the now-redundant per-handler AWSSDK.Lambda references.

* feat(DurableExecution): round-trip step/child results through the serializer on fresh success

On a fresh (non-replay) success, StepOperation and ChildContextOperation now return
the value deserialized from the just-written checkpoint instead of the original in-memory
object, matching replay semantics. This makes a custom per-operation serializer's
transform observable in the operation result on the first execution, not only on replay
(a non-round-tripping serializer previously had no effect on the fresh result).

Overflow (replay-children) child results are unaffected — the payload is stripped and the
value is recovered by re-execution. Behavior change documented in the AutoVer change file.

* test(DurableExecution): real per-op serdes conformance handlers for step 1-6 and child 3-14

Refactor StepCustomSerdes (1-6) to use a real StepConfig.Serializer (uppercase-on-serialize)
instead of transforming inside the step body, and add child/ChildCustomSerdes (3-14) using
ChildContextConfig.Serializer. Both rely on the fresh-success round-trip so the serialize-side
transform reaches the result. Remove the 3-14 NotImplemented and wire the resource in
template_child.yaml. Both pass against real AWS via the runner.

* address review: harden conformance serde handlers

- Custom serializers now guard typeof(T) == typeof(string) and throw a clear
  NotSupportedException for unsupported result types (instead of an opaque
  InvalidCastException from the (T)(object) cast).
- Read with an explicit UTF-8 StreamReader for symmetry with the UTF-8 bytes written.
- MapCustomSerdes: drop the async-without-await iteration lambda (CS1998); return
  Task.FromResult(...) so no per-item async state machine is allocated.
- InvokeCustomResultSerdes: correct the header comment — the serializer serializes the
  outbound request payload normally on the initial execution and applies the uppercase
  transform on deserialize (which, for a chained invoke, happens on replay).

The handlers still transform the raw serialized payload, which is the behavior the
conformance requirements assert (e.g. invoke 5-16 ExpectedResult '"HELLO"'); the string
path is byte-identical, so the suite results are unchanged.

* fix(DurableExecution): mark fresh-success round-trip change Major

The fresh-success serializer round-trip changes the observable return value
(fresh deserialized instance instead of the object the body produced, and a
non-round-tripping serializer's transform now visible on the first run). At the
released 1.0.0 baseline this is a breaking change and must be Major, not Minor.

* chore(DurableExecution): normalize 12a4a1f7 change file (strip trailing newline) to match canonical content
…l determinism (#2559)

Introduces the serializer engine that the FileSystemSerializer builds on, split out
of the original filesystem PR (#2558) for reviewability:

- Optional IDurableResultSerializer + DurableSerializationContext (EntityId +
  DurableExecutionArn) and LambdaSerializerHelper dispatch; plain ILambdaSerializer
  serializers are unaffected (byte-identical fallback).
- Extends the fresh-success serializer round-trip (established for Step/Child in
  the conformance work) to Map/Parallel Flat per-item results, eliminating a
  fresh-vs-replay divergence for non-round-tripping ItemSerializers.
- Map/Parallel Nested parent now inlines each child's ORIGINAL SUCCEED payload
  verbatim instead of re-serializing the round-tripped value.
- Terminal handling: a fresh-success round-trip deserialize failure (Step / Child /
  Flat) is terminal (no retry, body never re-run); Step post-SUCCEED enqueue failure
  routed through the same terminal path.

These changes are triggered by the per-operation serializer feature (#2555), not by
FileSystemSerializer specifically; FileSystemSerializer is the leaf that exercises them.

Note: two <see cref="FileSystemSerializer"/> doc links are rendered as <c>...</c> here
and restored to cref links in the FileSystemSerializer PR that introduces the type.
…to a filesystem (#2540) (#2558)

* feat(DurableExecution): FileSystemSerializer for offloading large results

Adds FileSystemSerializer (implements ILambdaSerializer + IDurableResultSerializer):
stores a durable operation's serialized result on a filesystem (EFS / S3 Files, NOT
Lambda /tmp) and keeps only a small {file}|{data} envelope in the checkpoint. Wraps an
inner ILambdaSerializer so callers control the on-the-wire format; supports
Always/Overflow storage modes and Uri/Hash path encoding; envelope is source-generated
(AOT-safe). The plain ILambdaSerializer path throws (offload needs the durable context).

Stacked on the serializer-engine PR; restores the two <see cref="FileSystemSerializer"/>
doc links now that the type exists. Includes the FileSystemSerializer AutoVer (Minor)
change file and unit tests.

* address review: inner-less FileSystemSerializer ctor uses global serializer

normj asked for a constructor that omits the inner ILambdaSerializer and
falls back to the durable execution's globally-registered serializer.

Adds FileSystemSerializer(basePath, storageMode, pathEncoding) which leaves
_inner null. The durable runtime binds the global serializer as the inner
when it resolves the effective per-operation serializer, via a new internal
IDefaultInnerSerializer capability (DurableContext.WithDefaultInner at the
Step/ChildContext/WaitForCondition/Parallel/Map resolution sites). An
explicitly-supplied inner always wins; used without a bound inner it throws a
clear InvalidOperationException.
@GarrettBeatty
GarrettBeatty changed the base branch from feature/per-step-serializer to feature/durable-result-serializer September 4, 2026 01:05
…constructor

Add a 'FileSystemSerializer' subsection to the steps custom-serializer docs
covering large-result offload to a durable mount, both the inner-taking and
the new inner-less constructor (runtime binds the globally-registered
serializer as the inner), storage/path-encoding modes, and the retention
caveat.
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 147d7a4 to 075c4d8 Compare September 4, 2026 01:16
GarrettBeatty added a commit that referenced this pull request Sep 4, 2026
…al-parallel overflow/await fixes, serializer deferral, Branch rename, docs

DurableExecution PR #2553 (stacked on feature/per-step-serializer-conformance).

1. [BLOCKER] StepOperation.ExecuteFunc: move the fresh-success SUCCEED enqueue
   and result round-trip OUTSIDE the try that funnels into HandleStepFailureAsync
   (mirrors ChildContextOperation). A serializer that cannot deserialize its own
   just-written payload now surfaces the fault directly instead of enqueuing a
   RETRY/FAIL that conflicts with the already-committed SUCCEED.

2. [MAJOR] .autover/changes/12a4a1f7: Minor -> Major. The package is GA (1.x) per
   CLAUDE.md, and the unconditional fresh-success round-trip is an observable
   happy-path behavior change for ALL serializers on non-suspending workflows
   (reference identity, DateTime.Kind, [JsonIgnore], precision). Not preview-exempt.

3. [MAJOR] IncrementalParallelOperation overflow recovery: isolate overflow-recovery
   re-runs (frozenStatus set) from _shortCircuitCts/_dispatchCts so a completion-policy
   short-circuit can no longer cancel them; and exclude frozen branches from the
   cooperative-bail arm so _result honors _frozenStatus (never resolves a frozen
   Succeeded branch to SkippedError, which made `await branch` throw while
   Status==Succeeded and lost the recovered value).

4. [MINOR] DurableContext.CreateParallel: defer LambdaSerializerHelper.GetRequired via
   a lazy factory (memoized in the operation). A workflow overriding the serializer on
   every branch no longer requires a global serializer at CreateParallel time (AOT /
   per-branch scenario). GetRequired is resolved only when a branch falls back.

5. [MINOR] Rename IDurableParallel.BranchAsync<T> -> Branch<T>. The method returns a
   handle synchronously (not a Task), so the Async suffix was misleading. Safe: the
   API is new/unreleased (absent on master). Updated the interface, impl, all call
   sites (conformance + tests), docs (parallel.md), and the AutoVer changelog text.

6. [MINOR] IDurableParallel.Branch XML doc: document ArgumentNullException,
   ObjectDisposedException, and NonDeterministicExecutionException in addition to
   InvalidOperationException.

7. [MINOR] IncrementalParallelBranch.ExecuteAsync: fault _result before rethrowing a
   workflow-level DurableExecutionException, so a caller that catches the fault out of
   CompleteAsync and then awaits the handle observes the fault instead of hanging.

8. [MINOR] Correct the IncrementalParallelOperation class summary and IParallelBranch.Index
   doc to reflect the 1-based operation-ID suffix (hash("{parentId}-{index+1}")).

9. [NIT] IncrementalParallelHeterogeneousTest: replace the tautological Contains("200")
   (satisfied by the "USD:4200" POCO branch) with the distinguishing token "Payment":200.

Tests: added 4 unit tests (fresh-success round-trip deserialize failure surfaces
without RETRY/FAIL; CreateParallel with no global serializer + per-branch overrides
does not throw; deferred fallback still throws on a non-overriding branch; a branch
faulting with a workflow-level error faults the handle instead of hanging). Build and
Amazon.Lambda.DurableExecution.Tests pass (447/447, net10.0). Integration-test and
deployed-function projects compile; the heterogeneous integration test requires an AWS
deployment and was not run here.

AutoVer: source changes are refinements to the two features already covered by the
existing change files, so both existing entries were updated (12a4a1f7 -> Major;
add-incremental changelog text updated for the Branch rename) rather than adding a new
change file. Reclassifying 12a4a1f7's Type was a one-field edit — the AutoVer CLI has
no edit verb, and adding a third Major entry would have left the mislabeled Minor in place.
GarrettBeatty added a commit that referenced this pull request Sep 4, 2026
… fix branch name-drift message

Address Copilot review on #2553:
- CreateParallel 'name' XML docs said a name change 'does not break replay',
  but the name is passed to ValidateReplayConsistency (throws on drift). Doc now
  states the name is part of the deterministic definition and must stay stable.
- Branch name-drift NonDeterministicExecutionException message had expected/found
  inverted; now reports the checkpointed name as expected and the current
  registration as the drifted value.
Adds an additive, branch-oriented parallel API alongside the existing
homogeneous ParallelAsync<T> overloads:

  await using var parallel = ctx.CreateParallel(name: "process-order");
  IParallelBranch<InventoryReservation> inv = parallel.BranchAsync("inventory", ...);
  IParallelBranch<PaymentAuthorization>  pay = parallel.BranchAsync("payment", ...);
  IBatchResult summary = await parallel.CompleteAsync();
  InventoryReservation r = await inv;   // own type, no shared base/cast/envelope

Each branch declares its own result type (heterogeneous) and returns an
awaitable typed handle; branches are registered incrementally and start
executing on registration (gated by MaxConcurrency); CompleteAsync seals,
awaits per CompletionConfig, and checkpoints the aggregate.

Implementation reuses the existing machinery so replay is identical to the
batch API: each branch runs as a ChildContextOperation<T> with the same
deterministic child op id (hash("{parentId}-{index}")) and the same parent
CONTEXT/Parallel BatchSummary checkpoint shape. Terminal-parent replay
reconstructs branch outcomes from the frozen inline summary (re-running only
overflow-stripped branches); DisposeAsync auto-completes so `await using`
always writes the terminal checkpoint. MaxConcurrency, CompletionConfig,
NestingType, cancellation, and ILambdaSerializer are honored unchanged.

Also factors the BatchSummary (de)serialization + overflow handling out of
ConcurrentOperation<T> into a shared BatchSummaryCodec so the batch and
incremental parallel paths cannot diverge on the wire format.

New public API:
- IDurableContext.CreateParallel(name?, config?)
- IDurableParallel (BranchAsync<T>, CompleteAsync, IAsyncDisposable)
- IParallelBranch<T> (Name/Index/Status, awaitable)

Tests: 16 unit tests (IncrementalParallelOperationTests) covering fresh happy
path, heterogeneous types, deterministic ids, MaxConcurrency, completion
short-circuit/skip, failure surfacing, empty, replay reconstruct, name-drift,
and STARTED-parent replay. Two integration tests (heterogeneous end-to-end and
replay determinism across the Run and Terminal-reconstruct paths), both
verified green against the durable execution service.

All 428 unit tests pass; docs/core/parallel.md documents the new API.
GarrettBeatty and others added 6 commits September 4, 2026 01:16
…#2519)

- Replay: switch explicitly on parent status — only SUCCEEDED reconstructs and
  only STARTED/PENDING re-run; any other terminal status (FAILED/CANCELLED/
  STOPPED/TIMED_OUT) throws NonDeterministicExecutionException instead of
  silently re-running and overwriting the prior outcome (mirrors
  ConcurrentOperation.ReplayAsync).
- CompleteAsync idempotence: cache the in-progress completion Task, not just the
  finished result, so concurrent CompleteAsync/DisposeAsync calls share one
  completion and enqueue exactly one parent SUCCEED.
- Terminal replay: enforce the positional replay contract — the registered
  branch count must equal the frozen summary's unit count, else throw.
- Percentage failure tolerance is no longer evaluated against the incomplete
  denominator during incremental registration; it is suppressed until the
  operation is sealed (CompletionPolicy gains an evaluatePercentage flag,
  defaulting true so batch behavior is unchanged).
- Observe the per-branch result-task fault in the handle ctor so a discarded
  failed handle cannot surface as an UnobservedTaskException.
- DisposeAsync no longer throws: its safety-net completion swallows faults.
- Docs: correct the CreateParallel `name` param (positional op id, not
  name-derived); document that the CompleteAsync token governs sealing/awaiting
  and does not retroactively cancel already-started branch bodies.

Adds 3 unit tests (unexpected-status throw, branch-count-mismatch throw,
percentage-not-evaluated-before-seal). 431 unit tests pass; both incremental
integration tests re-verified green against the durable execution service.
…r CreateParallel (#2519)

Stacks on the per-step-serializer work: CreateParallel now honors
ParallelConfig.ItemSerializer as the operation-level branch-result serializer,
and IDurableParallel.BranchAsync accepts an optional per-branch ILambdaSerializer
override (falls back to ItemSerializer, then the globally-registered serializer).
Each branch's serializer is threaded into both its ChildContextOperation and the
inline summary serialization so fresh and replay values match. Adds unit tests
for per-branch and operation-level ItemSerializer, and relaxes the timing-
sensitive FirstSuccessful test to its deterministic invariants.
* test(DurableExecution): add incremental parallel conformance handlers

* test(DurableExecution): add dedicated static typing suite

---------

Co-authored-by: Frank Chen <frankchn@dev-dsk-frankchn-2a-ff9871a5.us-west-2.amazon.com>
…al-parallel overflow/await fixes, serializer deferral, Branch rename, docs

DurableExecution PR #2553 (stacked on feature/per-step-serializer-conformance).

1. [BLOCKER] StepOperation.ExecuteFunc: move the fresh-success SUCCEED enqueue
   and result round-trip OUTSIDE the try that funnels into HandleStepFailureAsync
   (mirrors ChildContextOperation). A serializer that cannot deserialize its own
   just-written payload now surfaces the fault directly instead of enqueuing a
   RETRY/FAIL that conflicts with the already-committed SUCCEED.

2. [MAJOR] .autover/changes/12a4a1f7: Minor -> Major. The package is GA (1.x) per
   CLAUDE.md, and the unconditional fresh-success round-trip is an observable
   happy-path behavior change for ALL serializers on non-suspending workflows
   (reference identity, DateTime.Kind, [JsonIgnore], precision). Not preview-exempt.

3. [MAJOR] IncrementalParallelOperation overflow recovery: isolate overflow-recovery
   re-runs (frozenStatus set) from _shortCircuitCts/_dispatchCts so a completion-policy
   short-circuit can no longer cancel them; and exclude frozen branches from the
   cooperative-bail arm so _result honors _frozenStatus (never resolves a frozen
   Succeeded branch to SkippedError, which made `await branch` throw while
   Status==Succeeded and lost the recovered value).

4. [MINOR] DurableContext.CreateParallel: defer LambdaSerializerHelper.GetRequired via
   a lazy factory (memoized in the operation). A workflow overriding the serializer on
   every branch no longer requires a global serializer at CreateParallel time (AOT /
   per-branch scenario). GetRequired is resolved only when a branch falls back.

5. [MINOR] Rename IDurableParallel.BranchAsync<T> -> Branch<T>. The method returns a
   handle synchronously (not a Task), so the Async suffix was misleading. Safe: the
   API is new/unreleased (absent on master). Updated the interface, impl, all call
   sites (conformance + tests), docs (parallel.md), and the AutoVer changelog text.

6. [MINOR] IDurableParallel.Branch XML doc: document ArgumentNullException,
   ObjectDisposedException, and NonDeterministicExecutionException in addition to
   InvalidOperationException.

7. [MINOR] IncrementalParallelBranch.ExecuteAsync: fault _result before rethrowing a
   workflow-level DurableExecutionException, so a caller that catches the fault out of
   CompleteAsync and then awaits the handle observes the fault instead of hanging.

8. [MINOR] Correct the IncrementalParallelOperation class summary and IParallelBranch.Index
   doc to reflect the 1-based operation-ID suffix (hash("{parentId}-{index+1}")).

9. [NIT] IncrementalParallelHeterogeneousTest: replace the tautological Contains("200")
   (satisfied by the "USD:4200" POCO branch) with the distinguishing token "Payment":200.

Tests: added 4 unit tests (fresh-success round-trip deserialize failure surfaces
without RETRY/FAIL; CreateParallel with no global serializer + per-branch overrides
does not throw; deferred fallback still throws on a non-overriding branch; a branch
faulting with a workflow-level error faults the handle instead of hanging). Build and
Amazon.Lambda.DurableExecution.Tests pass (447/447, net10.0). Integration-test and
deployed-function projects compile; the heterogeneous integration test requires an AWS
deployment and was not run here.

AutoVer: source changes are refinements to the two features already covered by the
existing change files, so both existing entries were updated (12a4a1f7 -> Major;
add-incremental changelog text updated for the Branch rename) rather than adding a new
change file. Reclassifying 12a4a1f7's Type was a one-field edit — the AutoVer CLI has
no edit verb, and adding a third Major entry would have left the mislabeled Minor in place.
…c; add overflow-recovery terminal-path tests
… fix branch name-drift message

Address Copilot review on #2553:
- CreateParallel 'name' XML docs said a name change 'does not break replay',
  but the name is passed to ValidateReplayConsistency (throws on drift). Doc now
  states the name is part of the deterministic definition and must stay stable.
- Branch name-drift NonDeterministicExecutionException message had expected/found
  inverted; now reports the checkpointed name as expected and the current
  registration as the drifted value.
@GarrettBeatty
GarrettBeatty force-pushed the feature/heterogeneous-parallel branch from 075c4d8 to 8019228 Compare September 4, 2026 01:17
@GarrettBeatty
GarrettBeatty changed the base branch from feature/durable-result-serializer to master September 9, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants