Skip to content

Support Lambda Managed Instances in DurableExecution - #2605

Open
normj wants to merge 6 commits into
masterfrom
normj/lmi-durable-execution
Open

normj wants to merge 6 commits into
masterfrom
normj/lmi-durable-execution

Conversation

@normj

@normj normj commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Makes Amazon.Lambda.DurableExecution safe to run on Lambda Managed Instances (LMI), where several invocations share one process and Lambda does not stop an invocation that times out. Addresses the source-review findings in #2604.

1. Cancellation lost at teardown (all modes)

TerminationManager completes its task with RunContinuationsAsynchronously, so WorkflowCancellation received the cancel through a threadpool continuation. If the wrapper disposed the cancellation source first, the continuation hit ObjectDisposedException, which was swallowed, and the cancel was lost. The wrapper now cancels synchronously (BeginTeardown) before draining and disposing, and Dispose also cancels before releasing the source.

2. Abandoned work after the invocation ends (all modes)

When a workflow suspends, user work it started but did not await (a step still running, a losing parallel branch) kept running. On LMI the process stays live, so that work runs alongside other invocations.

  • The workflow token is cancelled when the invocation ends.
  • The token is captured at construction, so stale code that reads it after disposal gets a cancelled token instead of ObjectDisposedException.
  • Every IDurableContext operation entry point rejects new operations after teardown with OperationCanceledException.
  • The abandoned workflow task's fault is observed, so it can't surface through UnobservedTaskException in another invocation.

3. Invocation deadline (LMI only)

Only when AWS_LAMBDA_MAX_CONCURRENCY is set; on-demand behavior is unchanged.

  • The deadline comes from ILambdaContext.RemainingTime. Zero or negative means no deadline, so an unconfigured TestLambdaContext doesn't cancel immediately.
  • At the deadline: the workflow token is cancelled, new operations are rejected, and state hydration (GetExecutionStateAsync) is cancelled.
  • Checkpoint drain is bounded by the time left until the deadline plus a 2 second cleanup budget. Past that, the flush is cancelled and the batcher worker is abandoned rather than awaited.
  • WrapAsync throws TimeoutException. This escapes as an invocation error, the same outcome as an on-demand timeout, and is never recorded as a Failed durable execution, even if user code catches the cancellation and throws its own exception.
  • User code gets the full deadline; there's no early-firing margin.

Testing

Unit tests

  • New LambdaManagedInstancesTests (48 test methods) drive DurableFunction and its internals concurrently, using an in-memory service client:
    • Concurrent invocations through one shared handler, with every invocation inside a step at the same time: execution ARN, request ID, operation IDs, payloads and checkpoint token chains stay isolated.
    • A peer timing out while a healthy peer keeps running and succeeds.
    • Replay of one execution while a fresh peer is live: completed steps are skipped.
    • Teardown ordering in both modes: cancellation requested before dispose and never lost, token usable after dispose, new operations rejected.
    • The deadline hitting each point in an invocation:
      • during a step, state loading, the final drain, or output serialization;
      • while a START checkpoint is awaited or replay state is restored;
      • with a stalled checkpoint call, or an invocation that is already expired when it starts.
    • Cancellation callbacks registered by user code or the service client that block or throw. Neither runs on SDK threads, and neither can crash the process.
    • Code that converts or swallows the cancellation (step, child context, condition check, map/parallel branches): no FAIL or RETRY is recorded.
    • On-demand: no deadline, and state loading gets a token that can't be cancelled.
  • LMI mode and the deadline are driven through internal test seams rather than the process-wide environment variable and real timers:
    • Tests fire the deadline themselves once setup is in place, so setup never races a timer.
    • A timer that isn't armed models a timer callback the thread pool hasn't run yet.
    • Ordering uses signals, not sleeps, and no test asserts on elapsed time.
  • Every regression test was checked by reverting its fix and confirming the test fails.
  • Amazon.Lambda.DurableExecution.Tests (563) and Amazon.Lambda.DurableExecution.Testing.Tests (154) pass on net8.0 and net10.0, across repeated full runs.

Live testing on Lambda Managed Instances

These tests aren't in CI; provisioning LMI is too slow for it. I ran them by hand in a dev account (us-west-2) against this branch.

Setup

  • A capacity provider in the default VPC (x86_64, auto scaling).
  • A dotnet10 durable function on it:
    • 15 s timeout, 2 GB memory, JSON logging;
    • PerExecutionEnvironmentMaxConcurrency of 8, ExecutionTimeout of 900 s;
    • published versions, 3 execution environments each.
  • The function is an executable (bootstrap) that project-references this branch's Amazon.Lambda.DurableExecution, Amazon.Lambda.RuntimeSupport and Amazon.Lambda.Serialization.SystemTextJson, so the deployed code is exactly the PR.
  • It routes to a scenario by name and logs structured markers, including:
    • request ID, process instance and in-flight count;
    • remaining time, thread-pool and heap metrics;
    • every step entry, cancellation, retry attempt and callback.
  • The harness starts executions asynchronously (Invoke with --durable-execution-name), polls ListDurableExecutionsByFunction, completes callbacks with SendDurableExecutionCallbackSuccess, then checks two things:
    • CloudWatch Logs and the platform.report records for each invocation;
    • GetDurableExecutionHistory for each execution, to confirm what was actually recorded.

Several invocations always ran in the same process (up to 8 in flight per environment), and resumes often moved to a different environment.

Scenarios
"While young" means while the execution was younger than a set age, measured from when the harness started it. That lets the first invocations hit the 15 s deadline and the retries finish.

  • Isolation: dozens of concurrent step → wait → step executions. No log line carried another invocation's request ID, trace ID or executionArn scope.
  • Deadline:
    • a cooperative step waiting on its token while young;
    • a step that blocks a thread and ignores the token;
    • a 40-item map with the deadline landing mid-run.
  • Cancellation callbacks:
    • a step whose cancellation callback blocks for 20 s, while a parallel sibling suspends;
    • a step whose cancellation callback throws.
  • Code that swallows or converts the cancellation:
    • a condition check that swallows the cancellation and returns a state;
    • a step with a retry strategy, and a step inside a child context, each turning the cancellation into its own exception.
  • Other paths:
    • parallel suspension with a sibling step still running;
    • durable step retries;
    • callbacks;
    • about 6 MB of checkpoint history, so the resume invocation gets a NextMarker and has to load the rest of its state.
  • Warm load: 10 waves of mixed executions over about 100 invocations per process. Between waves, probes forced a full GC and recorded heap size, thread-pool size, OS threads, handles and active timers.

Results

  • Every execution succeeded: 227 across the three latest-code runs, and about 80 more in earlier runs during review. No execution failed.
  • Timeouts:
    • Cooperative steps were cancelled within ±8 ms of the deadline. Timed-out invocations surfaced as invocation errors, and the service retried them until the execution succeeded.
    • The histories show no StepFailed or retry events caused by a timeout. The swallowing condition check used no attempt, and the converting steps used no retry.
  • Map deadline: no item that had already succeeded ran again. Items cut off mid-step re-ran on retry, and each item's StepSucceeded was recorded exactly once.
  • Cancellation callbacks:
    • The invocation whose step had a 20 s blocking callback still returned Pending in about 130 ms.
    • The throwing callback was contained and logged at Warning under the right request ID, and the process kept serving work.
  • Code that ignores cancellation: the step that blocks a thread kept running after its invocation timed out, while the retry ran the same step in the same process. Its late result was never recorded, but its side effects still happened twice. This is documented under "Not covered".
  • Warm load: heap after a forced GC stayed at 1.0 to 1.3 MB per process, and handle and timer counts stayed flat. There were no unobserved task exceptions.

- Cancel the workflow token synchronously at teardown, before disposal.
  Termination delivered the cancel via a threadpool continuation that could
  run after disposal and be silently dropped.
- Clean up abandoned work: keep the workflow token readable after dispose,
  reject new durable operations once the invocation ends, and observe the
  abandoned workflow task's fault.
- Lambda Managed Instances only: apply the invocation deadline from
  RemainingTime. Cancel workflow work and state hydration at the deadline,
  bound checkpoint cleanup, and throw TimeoutException so the invocation is
  retried rather than recorded as a durable failure. On-demand behavior is
  unchanged.
- Add multi-concurrency unit tests for isolation, peer timeouts, replay
  alongside a live peer, and teardown ordering.

Refs #2604

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

Critical deadline and cancellation races can leave invocations running indefinitely, produce incorrect outcomes, and allow work or checkpoint writes after teardown.

12 open issues
What changed in this PR

Adds deadline and teardown handling to Amazon.Lambda.DurableExecution for Lambda Managed Instances, addressing part of #2604’s shared-process execution concerns.

Changes:

  • Adds invocation-scoped deadlines, timeout reporting, and bounded checkpoint cleanup.
  • Cancels abandoned workflow work and rejects new durable operations after teardown.
  • Adds concurrency and lifecycle tests, cancellation guidance, and patch release notes.
File Description
Libraries/​test/​Amazon.Lambda.DurableExecution.Tests/​LambdaManagedInstancesTests.cs Tests invocation isolation, replay, deadlines, and teardown.
Libraries/​src/​Amazon.Lambda.DurableExecution/​Internal/​WorkflowCancellation.cs Tracks cancellation, teardown, and LMI deadlines.
Libraries/​src/​Amazon.Lambda.DurableExecution/​Internal/​DurableInvocationOptions.cs Provides test overrides for LMI detection and cleanup budgets.
Libraries/​src/​Amazon.Lambda.DurableExecution/​Internal/​CheckpointBatcher.cs Adds timed draining and worker abandonment.
Libraries/​src/​Amazon.Lambda.DurableExecution/​DurableFunction.cs Integrates deadlines, hydration cancellation, and cleanup.
Libraries/​src/​Amazon.Lambda.DurableExecution/​DurableExecutionHandler.cs Races workflow execution against deadlines and observes abandoned faults.
Libraries/​src/​Amazon.Lambda.DurableExecution/​DurableContext.cs Guards operation entry points against teardown.
Libraries/​src/​Amazon.Lambda.DurableExecution/​docs/​core/​cancellation.md Documents LMI cancellation behavior and cooperative-code limits.
.autover/​changes/​46bb3a38-e1f3-48d1-ab9b-10a36475e901.json Records the patch release changes.

Review effort: Balanced

- Contain exceptions thrown by user cancellation callbacks so they cannot
  crash the shared process from the deadline timer or replace the outcome
  during teardown.
- Signal the deadline before cancelling, so a blocking user callback cannot
  stop the handler from seeing it; unregister rather than dispose the
  deadline registration.
- Read IsDeadlineExceeded from the deadline token, independent of which
  cancellation reason came first.
- Let the deadline decide the outcome whichever task wins the race.
- Treat an already-expired LMI invocation as an immediate timeout.
- Do not checkpoint FAIL or RETRY for exceptions raised after the deadline
  in steps, child contexts and condition checks.
- Re-check cancellation before running a step body, child function or
  condition check, and when the queued workflow actually starts.
- Re-check the deadline after the final drain, bound hydration even when the
  client ignores cancellation, and stop the batcher from sending further
  batches once it is shut down.
- Tests: add a regression test per fix and a hang guard that does not throw
  TimeoutException, so a hang can no longer pass a deadline test.

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

Cancellation callbacks can indefinitely block teardown or terminate the process, while deadline races can persist incorrect failure or retry checkpoints.

12 open issues
11 resolved since last review

Review effort: Balanced

- Treat an OCE as cancellation when the workflow token is cancelled even if
  the linked token has not been notified yet (step, child context, condition
  check), so a body that never ran is never recorded as failed.
- Map/parallel: check the workflow token directly before recording the parent
  outcome, matching IncrementalParallelOperation.
- Condition checks: do not record RETRY/FAIL/SUCCEED for a check that returned
  after the LMI deadline.
- Own the deadline timer so a throwing deadline-token callback (for example
  from the service client) cannot crash the process.
- Checkpoint batcher: cancel an abandoned flush with CancelAsync so blocking
  client callbacks cannot stall the drain, and release pending EnqueueAsync
  awaiters on abandonment.
- Use one combined deadline + cleanup budget for the final drain.
- Report a timeout when output serialization crosses the deadline.
- Always observe an abandoned hydration request's fault.
- Tests: regression test per fix; the deadline fault-race test registers on
  the deadline token so it exercises the race again; add a teardown-path test
  for throwing cancellation callbacks.

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.

Third round of Copilot review findings, addressed with one rule instead of
per-site patches: the SDK never runs cancellation callbacks it does not own
(user code, the service client) on its own threads, and it publishes its own
deadline and teardown state before any foreign code runs.

- Every cancel the SDK starts uses CancellationTokenSource.CancelAsync: tokens
  report cancellation immediately, callbacks run on the threadpool, and their
  exceptions are logged. BeginTeardown can no longer block in a user callback,
  in any mode.
- A short lock around starting the cancel ensures every caller returns only
  after the token reports cancellation.
- Sources are disposed only after callback delivery finishes, so no callback
  is skipped.
- The deadline is anchored to a monotonic timestamp taken when RemainingTime
  was read. IsDeadlineExceeded and admission compare the clock, and fire the
  deadline themselves if the timer is late.
- Firing the deadline records the reason, closes admission and signals
  DeadlineTask before cancelling the workflow token, then the client's
  deadline token. Hydration races DeadlineTask, not the client token.
- Map/parallel parents check the deadline before recording an outcome.
- Condition checks re-check the deadline after serializing state, on the
  RETRY, SUCCEED and max-attempts FAIL paths.
- Checkpoint batcher always observes an abandoned item's fault.
- Tests: deadline-dependent tests fire the deadline themselves after setup
  (TriggerDeadline) instead of racing a real 50 ms timer, and a test-only
  option leaves the timer unarmed to model a late timer.

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.

- Start the deadline-token and workflow-token cancels before signalling
  DeadlineTask, so a waiter that ends the invocation and disposes at once
  always finds the deadline cancel under way. Dispose and starting a cancel
  now coordinate under one lock: a cancel is either already started (and
  waited for) or never starts on a released source.
- Check the clock-based deadline immediately before running a step body, a
  child context function or a condition check, alongside the token check, so
  an awaited START or a replay state restore that crosses the deadline before
  its timer runs cannot start user code.
- Tests: barrier test disposing right after DeadlineTask is signalled;
  unarmed-timer tests for step and child START waits and condition replay;
  the step-deadline wrapper test now fires a controllable deadline after the
  step is entered instead of racing a real 100 ms deadline.
@normj
normj requested a balanced review from Copilot November 5, 2026 00:18

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@normj
normj requested a balanced review from Copilot November 5, 2026 00:33

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@normj
normj requested a balanced review from Copilot November 5, 2026 02:25

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

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

A moderate issue remains in deadline handling because hydration faults after the deadline can escape as SDK errors instead of being consistently translated into the promised timeout exception.

1 open issue

Review effort: Lite

A hydration request that faults after the LMI deadline (for example as it is cut off) is now reported as TimeoutException with the original as the inner exception, matching the rest of the wrapper.

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.

🔵 Needs a closer look

Human assessment is needed because the change contains multiple deadline, cancellation, and cleanup race conditions that can allow exceptions or checkpoint work to escape the promised timeout behavior.

No open issues 🎉

1 resolved since last review

Review effort: Lite

@normj
normj marked this pull request as ready for review November 11, 2026 01:11
@normj
normj requested review from a team as code owners November 11, 2026 01:11
@normj
normj requested review from afroz429 and sichanyoo November 11, 2026 01:11

This branch has not been deployed

No deployments
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