diff --git a/.autover/changes/durable-childcontext-virtual.json b/.autover/changes/durable-childcontext-virtual.json new file mode 100644 index 000000000..af46ffd0a --- /dev/null +++ b/.autover/changes/durable-childcontext-virtual.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.DurableExecution", + "Type": "Minor", + "ChangelogMessages": [ + "Add virtual-context support to standalone RunInChildContextAsync via ChildContextConfig.NestingType. Setting NestingType.Flat runs the child in a virtual context that emits no CONTEXT checkpoint of its own (mirroring MapConfig/ParallelConfig), enabling manual fan-out with Task.WhenAll while reducing checkpoint volume." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/ChildContextConfig.cs b/Libraries/src/Amazon.Lambda.DurableExecution/ChildContextConfig.cs index c00adf909..b2a5ec301 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/ChildContextConfig.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/ChildContextConfig.cs @@ -32,4 +32,26 @@ public sealed class ChildContextConfig /// is mapped before being thrown). /// public Func? ErrorMapping { get; set; } + + /// + /// How the child context is represented in the checkpoint graph. Defaults to + /// . + /// + /// + /// + /// Under the child runs in a virtual context + /// that emits no CONTEXT checkpoint of its own — reducing checkpoint + /// volume at the cost of less granular execution traces. Operations inside + /// the child (steps, waits, callbacks) still checkpoint, re-parented to this + /// context's parent. + /// + /// + /// This mirrors the NestingType.Flat option on + /// and , letting a + /// standalone + /// call opt into virtual contexts — for example when manually fanning out + /// work and awaiting the returned tasks with Task.WhenAll. + /// + /// + public NestingType NestingType { get; set; } = NestingType.Nested; } diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs index 5ff6c1bab..8dfabba19 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableContext.cs @@ -167,7 +167,8 @@ private Task RunChildContext( var op = new ChildContextOperation( operationId, name, _idGenerator.ParentId, func, config, serializer, MakeChildFactory(), - _state, _terminationManager, _workflowCancellation, _durableExecutionArn, _batcher); + _state, _terminationManager, _workflowCancellation, _durableExecutionArn, _batcher, + isVirtual: config?.NestingType == NestingType.Flat); return op.ExecuteAsync(cancellationToken); } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ChildContextOperationTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ChildContextOperationTests.cs index 1782fe933..2559760ea 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ChildContextOperationTests.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Tests/ChildContextOperationTests.cs @@ -610,4 +610,107 @@ await context.RunInChildContextAsync( } } + [Fact] + public async Task RunInChildContextAsync_FlatNesting_EmitsNoContextCheckpointAndReparentsInnerOps() + { + var (context, recorder, tm, _) = CreateContext(); + + var executed = false; + var result = await context.RunInChildContextAsync( + async (childCtx, _) => + { + executed = true; + return await childCtx.StepAsync(async (_, _) => { await Task.CompletedTask; return "inner"; }, name: "inner_step"); + }, + name: "phase", + config: new ChildContextConfig { NestingType = NestingType.Flat }); + + Assert.True(executed); + Assert.Equal("inner", result); + Assert.False(tm.IsTerminated); + + await recorder.Batcher.DrainAsync(); + + // A virtual (Flat) child emits no CONTEXT checkpoint of its own — only + // the inner step's operations are recorded. + var actions = recorder.Flushed.Select(o => $"{o.Type}:{o.Action}").ToArray(); + Assert.Equal(new[] + { + "STEP:START", + "STEP:SUCCEED" + }, actions); + Assert.DoesNotContain(recorder.Flushed, o => o.Type == "CONTEXT"); + + // Inner-op IDs still derive from the child's own operation ID (so sibling + // branches never collide), but they re-parent to the nearest non-virtual + // ancestor — here the root, whose ParentId is null — since the virtual + // branch has no CONTEXT checkpoint for them to reference. + var parentOpId = IdAt(1); + var innerStepId = ChildIdAt(parentOpId, 1); + var stepStart = recorder.Flushed.Single(o => o.Type == "STEP" && o.Action == "START"); + Assert.Equal(innerStepId, stepStart.Id); + Assert.Null(stepStart.ParentId); + } + + [Fact] + public async Task RunInChildContextAsync_FlatNesting_ReplayReExecutesBodyFromInnerCheckpoint() + { + // No CONTEXT checkpoint exists for a virtual child, so on replay the body + // re-executes; the inner step replays from its own SUCCEEDED checkpoint + // without re-running the step delegate. + var parentOpId = IdAt(1); + var innerStepId = ChildIdAt(parentOpId, 1); + var (context, recorder, _, _) = CreateContext(new InitialExecutionState + { + Operations = new List + { + new() + { + Id = innerStepId, + Type = OperationTypes.Step, + Status = OperationStatuses.Succeeded, + Name = "inner_step", + StepDetails = new StepDetails { Result = "\"cached\"" } + } + } + }); + + var stepRan = false; + var result = await context.RunInChildContextAsync( + async (childCtx, _) => + await childCtx.StepAsync(async (_, _) => + { + stepRan = true; + await Task.CompletedTask; + return "fresh"; + }, name: "inner_step"), + name: "phase", + config: new ChildContextConfig { NestingType = NestingType.Flat }); + + Assert.False(stepRan); + Assert.Equal("cached", result); + + await recorder.Batcher.DrainAsync(); + Assert.DoesNotContain(recorder.Flushed, o => o.Type == "CONTEXT"); + } + + [Fact] + public async Task RunInChildContextAsync_FlatNesting_FuncThrows_EmitsNoFailCheckpointButPropagates() + { + var (context, recorder, _, _) = CreateContext(); + + var ex = await Assert.ThrowsAsync(() => + context.RunInChildContextAsync( + (_, _) => throw new InvalidOperationException("boom"), + name: "phase", + config: new ChildContextConfig { NestingType = NestingType.Flat })); + + Assert.Equal("boom", ex.Message); + + await recorder.Batcher.DrainAsync(); + // Virtual child suppresses the CONTEXT FAIL checkpoint; the exception + // still propagates to the caller. + Assert.DoesNotContain(recorder.Flushed, o => o.Type == "CONTEXT"); + } + }