Repository navigation
fix(sdk): return FAILED when replay validation detects non-determinism - #867
Merged
Merged
Conversation
…ects non-determinism
Replay validation raises NonDeterministicExecutionError, the only production
error carrying TerminationReason.CUSTOM. runHandler had no branch for CUSTOM, so
it fell through to the catch-all and answered {Status: "PENDING"} with no error
and no log line. PENDING asks the service to keep the execution alive and invoke
again, but the fault is deterministic and permanent: every replay reproduces it,
the diagnostic the SDK had already built never reached the customer, and once
nothing was actually pending the service rejected the response outright with
"Cannot return PENDING status with no pending operations".
Invert the termination branch rather than add a third near-identical case.
Suspend reasons are now an allowlist (OPERATION_TERMINATED, WAIT_SCHEDULED,
RETRY_SCHEDULED, RETRY_INTERRUPTED_STEP, CALLBACK_PENDING) and every other reason
answers FAILED with the carried error, so a reason added later without a branch
here fails closed instead of silently claiming the execution is still
progressing. This also subsumes the CONTEXT_VALIDATION_ERROR and
CONFIG_VALIDATION_ERROR branches, which were copies of each other.
CHECKPOINT_FAILED and SERDES_FAILED still rethrow ahead of it, unchanged.
terminateForUnrecoverableError now carries the error object, not only its
message, so ErrorType reaches the customer as NonDeterministicExecutionError
rather than a bare Error. This also fixes a latent bug on the CHECKPOINT_FAILED
path: runHandler does `throw result.error`, which threw undefined whenever the
termination came from terminateForUnrecoverableError rather than from
CheckpointManager.
Tests: a composed test runs the real engine over a history checkpointed as
"step-a" while the handler replays "step-b", and asserts FAILED carrying the
diagnostic, with a stable-name control case. Pinned against the unfixed source it
returns {Status: "PENDING"}, reproducing the report. Unit coverage added for
CUSTOM -> FAILED with the type preserved, CONTEXT_VALIDATION_ERROR -> FAILED
(previously untested), an unclassified reason failing closed, and all five
suspend reasons -> PENDING; only OPERATION_TERMINATED was covered before.
This is the status half of #865. Item 3 of that issue -- validateReplayConsistency
discarding its never-resolving promise, which lets handler code keep running
against checkpoint data known to belong to a different operation -- is a separate
defect with a wider blast radius, and follows in its own change.
This changes observable invocation status for a public failure mode, so it looks
like a candidate for a conformance test in
https://github.com/aws/aws-durable-execution-conformance-tests.
Refs #865
ParidelPooya
had a problem deploying
to
ai-pr-review
August 26, 2026 06:40 — with
GitHub Actions
Error
…y reasons beside the enum
Review follow-up. Three changes and one correction.
Non-determinism gets TerminationReason.NON_DETERMINISM instead of borrowing CUSTOM.
CUSTOM said nothing about whether the execution could continue, so relying on its
default to mean "fault" conflated "unclassified" with "fatal". CUSTOM remains, still
defaulting to fault, and is now pinned by its own test so that default cannot drift
unnoticed.
The suspend/fault classification moves from a Set local to with-durable-execution.ts
into TERMINATION_CLASS beside the enum it classifies, typed
Record<TerminationReason, TerminationClass>. Adding a reason without classifying it
is now a compile error -- verified by adding a probe member, which fails with TS2741
naming the missing key. A Set in the consumer could not give that signal, which was
the omission the fail-closed default existed to contain; the default is kept for
values arriving from outside the enum as plain strings. The rationale is stated once,
on the map, rather than restated at the branch that reads it.
Plugin coverage added for the classification, since PluginInvocationStatus is public
surface: a NON_DETERMINISM termination reports FAILED carrying the error where it
previously reported PENDING with none, and a suspend still reports PENDING.
Correction. The previous commit claimed the error pass-through fixes `throw
result.error` throwing undefined on a transport checkpoint failure. That is wrong.
CheckpointManager's queue-processing catch already passed `error`, and
TerminationManager's isTerminated guard means the first terminate call wins, so that
path was always correct -- a composed test driving a 5xx confirms it passes with the
pass-through removed. The bug is real but reached differently: when an
UnrecoverableError classified CHECKPOINT_FAILED reaches step-handler's catch without
the checkpoint queue having failed, terminateForUnrecoverableError terminates first,
and the invocation resolved {Status: FAILED, Error: {ErrorMessage: "Unknown error"}}
instead of throwing -- permanently failing an execution that should have retried,
with nothing naming the cause. Both paths now have composed tests; the second fails
without the pass-through, the first is a regression guard.
Also drops an assertion that proved nothing: the step body not running in the
step-name scenario follows from that checkpoint already being SUCCEEDED, not from
anything in this change.
Refs #865
ParidelPooya
had a problem deploying
to
ai-pr-review
August 26, 2026 07:01 — with
GitHub Actions
Error
…int-failure test The file added 5 eslint warnings, taking the package from its 21-warning baseline to 26. Four came from a literal response object inside a /** */ block, which tsdoc read as a malformed inline tag and unescaped braces; that detail now sits in the // comment on the test it belongs to, where tsdoc does not apply and it is next to the assertion it explains. The fifth was a missing return type on the failing client's checkpoint arrow, now Promise<never>, which is what a function that only throws returns. The 21-line header is down to 10. What it kept is the part not in the PR description: that a CHECKPOINT_FAILED termination has to throw rather than resolve, and that two callers race to raise it with the first winning, which is why there is a test per caller. What each caller does differently moved onto the tests themselves. Back to 21 warnings, and the second test still fails without the error pass-through. Refs #865
ParidelPooya
had a problem deploying
to
ai-pr-review
August 26, 2026 16:25 — with
GitHub Actions
Error
ParidelPooya
marked this pull request as ready for review
August 27, 2026 17:44
ParidelPooya
had a problem deploying
to
ai-pr-review-runtime
August 27, 2026 17:44 — with
GitHub Actions
Error
ParidelPooya
had a problem deploying
to
ai-pr-review-runtime
August 27, 2026 17:44 — with
GitHub Actions
Error
ParidelPooya
had a problem deploying
to
ai-pr-review-runtime
August 27, 2026 17:44 — with
GitHub Actions
Error
This was referenced Sep 2, 2026
ParidelPooya
added a commit
that referenced
this pull request
Sep 3, 2026
…AT map items (#894) `context.map` and `context.parallel` with `nesting: FLAT` rebuilt an **empty** result on replay when the batch was large enough to be summarized, even though every item had succeeded. A batch whose aggregate result exceeds the checkpoint size limit is stored as a summary with `ReplayChildren`, and each later resumption reconstructs it from the per-item checkpoints in `ConcurrencyController.replayItems`. That reconstruction decided whether an item had finished by probing the item's context checkpoint — but with `FLAT` nesting the per-item contexts are virtual and never checkpointed, so every item read as unfinished and was skipped. Not cosmetic: code deriving control flow from the batch result, such as a fan-out sized from the results, then creates a different set of operations on replay than it did live. The replay-consistency check detects the divergence and raises `NonDeterministicExecutionError`, which SDKs at or below 2.3.0 reported as a `PENDING` response rather than a failure. While other work is still draining `PENDING` is a legal answer, so the corruption is invisible; once nothing is left pending the service rejects the response with "Cannot return PENDING status with no pending operations" and the execution fails after deterministic retries. Batches whose result fits in one checkpoint are replayed by deserialization and never enter this path. ## Change The batch payload now records which items reached a terminal state, one character per item in index order. Replay reads that record instead of inferring it. Only terminality is recorded — an item's `SUCCEEDED`/`FAILED` outcome still comes from re-driving it. Terminality resolves in order of authority: 1. the recorded statuses, with any context record the item has required to agree 2. the item's own context checkpoint, for payloads with no recorded statuses 3. a probe of the item's operations, for payloads predating the field The agreement requirement in (1) is one-sided on purpose. A marker saying unfinished overrides a terminal context record, because the statuses are what the live `BatchResult` contained. The reverse is not honoured: re-driving an item whose context checkpoint is non-terminal hits `checkForNonResolvingPromise` and the reconstruction never settles (#751). The probe in (3) walks every recorded operation and requires all of them to be terminal. Reading only the first is unsound — a mid-flight item can have a `SUCCEEDED` first operation, and re-driving it would execute its unfinished operations for real. Two payload shapes are recognised, since `ReplayChildren` is reached two ways: the summary envelope carrying `itemStatuses`, and the full serialized result written when `childOperationsDepth` opts a context into child preservation, which has no summary envelope but does carry `all: [{ index, status }]`. Parsing is validated as strictly as `parseRecordedCompletionReason`. There is deliberately no size handling on the record. Every batch item runs its body inside a context, and a context is meant to wrap at least one durable operation, so each item costs at least one. The service caps an execution at 30,000 operations, which bounds any batch at roughly that many items — about 30 KB of markers at one byte each, an order of magnitude inside the 256 KB checkpoint limit. Capping the marker count instead would be actively harmful: dropping the field sends replay back to probing, and probing cannot recover an item whose body created no durable operation, which is the corruption this record exists to prevent. A test pins the bound at the largest batch the service allows. ## Behaviour change A virtual item with no durable operation of its own is now re-driven on replay, so any part of its mapper body not wrapped in a durable operation runs again. This is the documented contract for virtual contexts and what `NESTED` already does, but it is new on this path. That shape is against best practice regardless — a context is a container for durable operations, and one with nothing durable inside it is not checkpointed, so its work re-runs on every replay anyway. The TSDoc for `DurableContext.runInChildContext` and `MapFunc` now says so, and #896 tracks an eslint rule for it. ## Testing New `examples/map/flat-summarized-replay` covering the rebuild across a suspension for `FLAT` and `NESTED`, an item that checkpoints no durable operation, and a small-result control. It is `localOnly`: the suspension depends on the local runner's virtual clock, so deploying it would only produce a skipped cloud test. Every case asserts at least two invocations, so none can pass without actually suspending. Against unpatched code both `FLAT` cases fail (`replayedResultCount` 0 against a live count of 8) while the controls pass. 14 new unit cases cover both directions of a statuses/record disagreement, the `childOperationsDepth` shape, a crafted `itemStatuses` value, the probe rejecting a mid-flight item, and the size bound at the service's operation limit. The `PENDING` misreport that hid this was fixed separately on mainline by #867; this addresses the underlying corruption.
ParidelPooya
added a commit
that referenced
this pull request
Sep 3, 2026
Bumps @aws/durable-execution-sdk-js from 2.3.0 to 2.3.1. This PR exists to cut the release tag for what has already landed on main; the only changes are the version field, the testing package's peer-dependency upper bound, and the lockfile entries for both. Patch rather than minor: all three SDK changes since sdk-2.3.0 are fixes, and none adds public API. The checkpoint payload gains one field (#894), which is compatible in both directions — an older SDK ignores it, and a newer SDK falls back to probing on payloads written without it. **Fixed** - Summarized replay of a FLAT-nested map or parallel rebuilt an EMPTY result even though every item had succeeded (#894). Such a batch is checkpointed as a summary and reconstructed from its per-item checkpoints on each later resumption, but the reconstruction decided whether an item had finished by probing the item's context checkpoint — and with FLAT nesting those per-item contexts are virtual and never checkpointed, so every item read as unfinished and was skipped. Code deriving control flow from the batch result then created a different set of operations on replay than it had live, which replay validation reported as a non-determinism failure. The batch payload now records which items reached a terminal state and replay reads that record. One behaviour change on this path: a virtual item that performs no durable operation of its own is now re-driven, so any part of its mapper body outside a durable operation runs again — the same as NESTED already does, and a shape the TSDoc now discourages. - Replay validation now answers FAILED instead of PENDING (#867). A NonDeterministicExecutionError is the only production error carrying TerminationReason.CUSTOM, and runHandler had no branch for it, so it fell through to the catch-all and answered {Status: "PENDING"} with no error and no log line. PENDING asks the service to keep the execution alive and invoke again, but the fault is deterministic: every replay reproduced it, the diagnostic the SDK had already built never reached the customer, and once nothing was actually pending the service rejected the response with "Cannot return PENDING status with no pending operations". Suspend reasons are now an allowlist and every other reason answers FAILED carrying the error, so a reason added later without a branch fails closed instead of silently claiming progress. - wait-for-condition surfaces serdes failures instead of silently restarting (#754). The handler used a bare catch around a native serdes.deserialize, so a checkpointed polling state that could not be deserialized was swallowed and the condition loop restarted from config.initialState. It now uses safeDeserialize, which logs, rethrows SerDesException and terminates through the termination manager, matching the Java and Python SDKs.
ParidelPooya
added a commit
that referenced
this pull request
Sep 3, 2026
Bumps @aws/durable-execution-sdk-js from 2.3.0 to 2.3.1. This PR exists to cut the release tag for what has already landed on main; the only changes are the version field, the testing package's peer-dependency upper bound, and the lockfile entries for both. Patch rather than minor: all three SDK changes since sdk-2.3.0 are fixes, and none adds public API. The checkpoint payload gains one field (#894), which is compatible in both directions — an older SDK ignores it, and a newer SDK falls back to probing on payloads written without it. **Fixed** - Summarized replay of a FLAT-nested map or parallel rebuilt an EMPTY result even though every item had succeeded (#894). Such a batch is checkpointed as a summary and reconstructed from its per-item checkpoints on each later resumption, but the reconstruction decided whether an item had finished by probing the item's context checkpoint — and with FLAT nesting those per-item contexts are virtual and never checkpointed, so every item read as unfinished and was skipped. Code deriving control flow from the batch result then created a different set of operations on replay than it had live, which replay validation reported as a non-determinism failure. The batch payload now records which items reached a terminal state and replay reads that record. One behaviour change on this path: a virtual item that performs no durable operation of its own is now re-driven, so any part of its mapper body outside a durable operation runs again — the same as NESTED already does, and a shape the TSDoc now discourages. - Replay validation now answers FAILED instead of PENDING (#867). A NonDeterministicExecutionError is the only production error carrying TerminationReason.CUSTOM, and runHandler had no branch for it, so it fell through to the catch-all and answered {Status: "PENDING"} with no error and no log line. PENDING asks the service to keep the execution alive and invoke again, but the fault is deterministic: every replay reproduced it, the diagnostic the SDK had already built never reached the customer, and once nothing was actually pending the service rejected the response with "Cannot return PENDING status with no pending operations". Suspend reasons are now an allowlist and every other reason answers FAILED carrying the error, so a reason added later without a branch fails closed instead of silently claiming progress. - wait-for-condition surfaces serdes failures instead of silently restarting (#754). The handler used a bare catch around a native serdes.deserialize, so a checkpointed polling state that could not be deserialized was swallowed and the condition loop restarted from config.initialState. It now uses safeDeserialize, which logs, rethrows SerDesException and terminates through the termination manager, matching the Java and Python SDKs.
ParidelPooya
added a commit
that referenced
this pull request
Sep 3, 2026
Bumps @aws/durable-execution-sdk-js from 2.3.0 to 2.3.1. This PR exists to cut the release tag for what has already landed on main; the only changes are the version field, the testing package's peer-dependency upper bound, the lockfile entries for both, and a CHANGELOG roll. The CHANGELOG had not been rolled since 2.0.0, so `[Unreleased]` was carrying three releases' worth of notes at once: the runtimes-without-AsyncLocalStorage entry that shipped in 2.3.0, the eslint-plugin 1.1.0 entries that shipped separately, and this release's fixes. Those are now under `[2.3.0]`, `[eslint-plugin 1.1.0]` and `[2.3.1]`, with `[Unreleased]` left empty. No wording changed; the one addition is an entry for #867, which had none. Note that 2.1.0 through 2.3.0 remain only partially documented here — the full notes for those releases live in their bump PR bodies (#822 and earlier) and were never copied into this file. Reconstructing them is left for a follow-up. Patch rather than minor: all three SDK changes since sdk-2.3.0 are fixes, and none adds public API. The checkpoint payload gains one field (#894), which is compatible in both directions — an older SDK ignores it, and a newer SDK falls back to probing on payloads written without it. **Fixed** - Summarized replay of a FLAT-nested map or parallel rebuilt an EMPTY result even though every item had succeeded (#894). Such a batch is checkpointed as a summary and reconstructed from its per-item checkpoints on each later resumption, but the reconstruction decided whether an item had finished by probing the item's context checkpoint — and with FLAT nesting those per-item contexts are virtual and never checkpointed, so every item read as unfinished and was skipped. Code deriving control flow from the batch result then created a different set of operations on replay than it had live, which replay validation reported as a non-determinism failure. The batch payload now records which items reached a terminal state and replay reads that record. One behaviour change on this path: a virtual item that performs no durable operation of its own is now re-driven, so any part of its mapper body outside a durable operation runs again — the same as NESTED already does, and a shape the TSDoc now discourages. - Replay validation now answers FAILED instead of PENDING (#867). A NonDeterministicExecutionError is the only production error carrying TerminationReason.CUSTOM, and runHandler had no branch for it, so it fell through to the catch-all and answered {Status: "PENDING"} with no error and no log line. PENDING asks the service to keep the execution alive and invoke again, but the fault is deterministic: every replay reproduced it, the diagnostic the SDK had already built never reached the customer, and once nothing was actually pending the service rejected the response with "Cannot return PENDING status with no pending operations". Suspend reasons are now an allowlist and every other reason answers FAILED carrying the error, so a reason added later without a branch fails closed instead of silently claiming progress. - wait-for-condition surfaces serdes failures instead of silently restarting (#754). The handler used a bare catch around a native serdes.deserialize, so a checkpointed polling state that could not be deserialized was swallowed and the condition loop restarted from config.initialState. It now uses safeDeserialize, which logs, rethrows SerDesException and terminates through the termination manager, matching the Java and Python SDKs.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replay validation raises NonDeterministicExecutionError, the only production error carrying TerminationReason.CUSTOM. runHandler had no branch for CUSTOM, so it fell through to the catch-all and answered {Status: "PENDING"} with no error and no log line. PENDING asks the service to keep the execution alive and invoke again, but the fault is deterministic and permanent: every replay reproduces it, the diagnostic the SDK had already built never reached the customer, and once nothing was actually pending the service rejected the response outright with "Cannot return PENDING status with no pending operations".
Invert the termination branch rather than add a third near-identical case. Suspend reasons are now an allowlist (OPERATION_TERMINATED, WAIT_SCHEDULED, RETRY_SCHEDULED, RETRY_INTERRUPTED_STEP, CALLBACK_PENDING) and every other reason answers FAILED with the carried error, so a reason added later without a branch here fails closed instead of silently claiming the execution is still progressing. This also subsumes the CONTEXT_VALIDATION_ERROR and CONFIG_VALIDATION_ERROR branches, which were copies of each other. CHECKPOINT_FAILED and SERDES_FAILED still rethrow ahead of it, unchanged.
terminateForUnrecoverableError now carries the error object, not only its message, so ErrorType reaches the customer as NonDeterministicExecutionError rather than a bare Error. This also fixes a latent bug on the CHECKPOINT_FAILED path: runHandler does
throw result.error, which threw undefined whenever the termination came from terminateForUnrecoverableError rather than from CheckpointManager.Tests: a composed test runs the real engine over a history checkpointed as "step-a" while the handler replays "step-b", and asserts FAILED carrying the diagnostic, with a stable-name control case. Pinned against the unfixed source it returns {Status: "PENDING"}, reproducing the report. Unit coverage added for CUSTOM -> FAILED with the type preserved, CONTEXT_VALIDATION_ERROR -> FAILED (previously untested), an unclassified reason failing closed, and all five suspend reasons -> PENDING; only OPERATION_TERMINATED was covered before.
This is the status half of #865. Item 3 of that issue -- validateReplayConsistency discarding its never-resolving promise, which lets handler code keep running against checkpoint data known to belong to a different operation -- is a separate defect with a wider blast radius, and follows in its own change.
This changes observable invocation status for a public failure mode, so it looks like a candidate for a conformance test in
https://github.com/aws/aws-durable-execution-conformance-tests.
Refs #865