Skip to content

Add Roslyn analyzers (DE001-DE004) for Amazon.Lambda.DurableExecution#2443

Open
GarrettBeatty wants to merge 1 commit into
devfrom
durable-execution-analyzers
Open

Add Roslyn analyzers (DE001-DE004) for Amazon.Lambda.DurableExecution#2443
GarrettBeatty wants to merge 1 commit into
devfrom
durable-execution-analyzers

Conversation

@GarrettBeatty

@GarrettBeatty GarrettBeatty commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Roslyn analyzer package (Amazon.Lambda.DurableExecution.Analyzers) implementing DE001–DE004, the .NET counterpart of the JavaScript SDK's @aws/durable-execution-sdk-js-eslint-plugin. This is the P1 follow-up described in Docs/durable-execution-design.md §"Roslyn Analyzers".

The analyzer assembly is bundled inside the Amazon.Lambda.DurableExecution NuGet package (analyzers/dotnet/cs), so it activates automatically for any project that references the package — no separate package or extra reference. Detection is semantic (durable operations are matched by the Amazon.Lambda.DurableExecution.IDurableContext symbol), which avoids the method-name false positives the AST-only JS plugin documents.

Why this exists

A durable workflow has no persisted program counter. On every invocation — including every replay after a wait, retry, or failure — the handler runs again from the top, and each durable call either replays its checkpointed result or runs fresh. For the cached results to line up with the code, the workflow must run the same operations, in the same order, every time. These analyzers catch the four most common ways that contract gets broken, at author time instead of as a confusing runtime replay failure.

Diagnostics

ID Severity Rule
DE001 Warning Non-deterministic API used outside a step
DE002 Warning Durable operation invoked inside a step-wrapped delegate via the captured outer IDurableContext
DE003 Warning Mutation of a captured outer-scope variable inside a durable-operation delegate
DE004 Info Task.WhenAll/Task.WhenAny over durable tasks; prefer ParallelAsync/MapAsync

DE001 — Non-deterministic call outside a step

Flags DateTime.Now/UtcNow/Today, DateTimeOffset.Now/UtcNow, Guid.NewGuid(), new Random() / Random.Shared, Stopwatch.GetTimestamp()/StartNew()/Elapsed*, Environment.TickCount/TickCount64, RandomNumberGenerator/RNGCryptoServiceProvider, and Path.GetTempFileName()/GetRandomFileName() when used in workflow code outside a step. The value would differ between the original run and replays. (A seeded new Random(42) is deterministic and is not flagged.)

public async Task<string> Run(Order input, IDurableContext context)
{
    var id = Guid.NewGuid();                       // ❌ DE001 — different Guid on every replay
    var now = DateTime.UtcNow;                     // ❌ DE001
    await context.StepAsync((s, ct) => Save(id, now));

    // ✅ capture inside a step so the value is checkpointed once
    var id2 = await context.StepAsync((s, ct) => Task.FromResult(Guid.NewGuid()));
    // ✅ a seeded Random is deterministic — not flagged
    var seeded = new Random(42).Next();
}

DE002 — Nested durable operation inside a step body

Flags any durable operation (StepAsync, WaitAsync, ParallelAsync, MapAsync, InvokeAsync, RunInChildContextAsync, CreateCallbackAsync, WaitForCallbackAsync) invoked inside a step-wrapped delegate — a StepAsync body, a WaitForCallbackAsync submitter, or a WaitForConditionAsync check — by capturing the outer IDurableContext. Step bodies are leaf operations; group durable operations with RunInChildContextAsync instead.

// ❌ DE002 — durable op nested inside a step body (uses the captured outer context)
await context.StepAsync(async (s, ct) =>
{
    await context.WaitAsync(TimeSpan.FromSeconds(1));
});

// ✅ group with a child context, operating on the child's own context
await context.RunInChildContextAsync(async (child, ct) =>
{
    await child.StepAsync((s, c) => DoWork());
    await child.WaitAsync(TimeSpan.FromSeconds(1));
});

DE003 — Mutable variable captured and modified inside a durable operation

Flags assignment, compound assignment, or increment/decrement of a variable captured from an outer scope inside a durable-operation delegate. On replay the body is skipped and the cached result returned, so the write never happens and the captured variable holds stale state. Reading a captured variable is safe and is not flagged.

int total = 0;

// ❌ DE003 — the write is lost on replay, so `total` is wrong after a resume
await context.StepAsync((s, ct) => { total += 1; return Task.CompletedTask; });

// ✅ return the value from the step and assign it
total = await context.StepAsync((s, ct) => Task.FromResult(total + 1));

// ✅ reading a captured variable is fine
await context.StepAsync((s, ct) => Task.FromResult(total + 10));

DE004 — Task.WhenAll/Task.WhenAny over durable tasks

Advisory (Info). Task.WhenAll/Task.WhenAny work correctly with durable tasks — operation IDs are allocated deterministically via Interlocked.Increment — but they bypass completion policies, concurrency limits, branch naming, and structured IBatchResult output. Prefer ParallelAsync/MapAsync.

// ℹ️ DE004 — works, but no completion policy / concurrency limit / IBatchResult
await Task.WhenAll(
    context.StepAsync((s, ct) => Charge(a)),
    context.StepAsync((s, ct) => Charge(b)));

// ✅ preferred
await context.ParallelAsync(new Func<IDurableContext, CancellationToken, Task<Receipt>>[]
{
    (c, ct) => c.StepAsync((s, t) => Charge(a)),
    (c, ct) => c.StepAsync((s, t) => Charge(b)),
});

validated they diagnostics show up in visual studio

image

Tests / verification

  • 34 unit tests (Microsoft.CodeAnalysis.CSharp.Analyzer/CodeFix.Testing.XUnit 1.1.2) covering the JS-rule scenarios plus .NET-specific cases (step-wrapped suppression, child-context legality, shadowing, Parallel branches). All pass.
  • SDK self-build verified clean (analyzer correctly does not run on it).
  • dotnet pack verified: analyzer lands in analyzers/dotnet/cs, not lib/.
  • End-to-end smoke test against the packed package: DE001–DE004 all fire with correct messages and help links; .editorconfig override path confirmed.

Ports the JavaScript SDK's ESLint plugin to Roslyn and adds a .NET-specific
rule, bundled into the Amazon.Lambda.DurableExecution package so they activate
automatically for consumers (analyzers/dotnet/cs).

- DE001 (Warning): non-deterministic API (DateTime.Now, Guid.NewGuid, Random,
  Stopwatch, Environment.TickCount, crypto RNG) used outside a step + code fix.
- DE002 (Warning): durable operation invoked inside a step-wrapped delegate via
  the captured outer IDurableContext.
- DE003 (Warning): mutation of a captured outer-scope variable inside a
  durable-operation delegate.
- DE004 (Info): Task.WhenAll/WhenAny over durable tasks; prefer
  ParallelAsync/MapAsync + code fix.

Detection is semantic (matches IDurableContext by symbol), not name-based like
the JS plugin. A shared per-compilation symbol cache bails out when the SDK is
not referenced, so the analyzers are zero-cost on non-durable projects. 34 unit
tests via Microsoft.CodeAnalysis.*.Testing; verified end-to-end against a packed
consumer.
@GarrettBeatty GarrettBeatty marked this pull request as ready for review June 25, 2026 16:48
@GarrettBeatty GarrettBeatty requested review from a team as code owners June 25, 2026 16:48
@GarrettBeatty GarrettBeatty requested review from normj and philasmar June 25, 2026 16:48
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.

2 participants