Repository navigation
fix(sdk): suspend with PENDING when a checkpoint response has no token - #931
Merged
Merged
Conversation
Every checkpoint response carries the token for the next call, so a response without one withdraws this invocation's ability to record anything further -- for instance because a newer invocation has taken the execution over. The checkpoint manager ignored the omission, kept the token it had just spent and presented it again, which the service rejects with `InvalidParameterValueException: Invalid checkpoint token`. Since #920 that is an invocation error, so a condition the SDK could recognise ended the invocation with a Lambda error one call later. It now stops checkpointing and ends the invocation with `{Status: "PENDING"}` through the existing suspend path, under a new internal termination reason `EXECUTION_SUSPENDED_BY_SERVICE`. The set of invocation responses is unchanged. Whatever had not been sent is abandoned rather than checkpointed and replays next invocation. `NewExecutionState` on the token-less response is deliberately not applied: applying it would wake operations the handler is awaiting and let it run on past the point where the service can be told anything. The condition is logged at WARN, since an absent token is indistinguishable from a client that dropped the field. The testing SDK gains `withholdCheckpointTokenOnCall`, which makes the local checkpoint server answer an execution's nth checkpoint call, and only that one, without a token -- so a handler can be tested against having work it started thrown away mid-invocation. Closes #918
Replaces setupTestEnvironment's withholdCheckpointTokenOnCall with LocalDurableTestRunner.pauseExecution() and resumeExecution(). A call count chosen up front ties a test to the SDK's checkpoint batching; pausing lets the test pick the moment from what it observes. Pausing marks the execution paused on the local checkpoint server, which answers its checkpoints without a CheckpointToken, and makes the orchestrator hold back every invocation until resumed. pauseExecution() resolves once no invocation is running. resumeExecution() starts the held-back invocation, or one for a PENDING that would otherwise be rejected as having nothing pending. Writing the first test exposed a gap in the SDK change: the accepted batch's callers were still resolved, so the handler started its next step after the service had stopped listening, and that work then ran again next invocation. They are now left unresolved like any other terminated checkpoint -- except a batch carrying the execution's own terminal update (the oversized-result path), whose caller awaits it outside the termination race and would otherwise hold the invocation until Lambda's timeout. Refs #918
…ud runner pauseExecution() and resumeExecution() are tagged @experimental and added to the DurableTestRunner interface, so a test written against the interface compiles for either runner. CloudDurableTestRunner rejects both with a not-implemented error without calling the service: pausing relies on the checkpoint server withholding tokens, and the service offers no call for that. Refs #918
Adds src/examples/pause-resume, an order workflow with tests for pausing mid-step and pausing while a callback is outstanding. It is marked localOnly: the service offers no way to withhold checkpoint tokens, so it is left out of the SAM template and its tests are skipped under NODE_ENV=integration. The example exposed a hang with skipTime: an invocation paused mid-step was never continued. The queue scheduler counts the invocation that is ending as scheduled work, so the orchestrator concluded something else would continue the execution. A PENDING while paused now always leaves resume() an invocation to start. A testing-package regression test covers it. Also fixes `tsc --noEmit` in the examples package failing in any checkout where `generate-examples` has not run, because examples-catalog.ts imports the generated, gitignored examples-catalog.json. A declaration file (examples-catalog.d.json.ts, via allowArbitraryExtensions) now gives tsc the types, and rollup resolves the import to the real JSON ahead of the TypeScript plugin, whose resolver would otherwise prefer the declaration. Refs #918
…r returns Two paths still misreported an invocation whose checkpoint token the service withdrew after the handler had already won the race against termination: 1. A handler returns or throws while a checkpoint it did not await is in flight, for example from unfinished map/parallel branches. If that checkpoint is answered without a token, the invocation reported SUCCEEDED with its result, or FAILED, although the service will record neither. It now answers PENDING. 2. The oversized-result checkpoint waited in the queue behind that batch. The queue was cleared without sending it, the await on it never settled, and the invocation ran until the Lambda timeout. The await is now raced against termination: a withdrawn token answers PENDING, and a failed checkpoint throws its classified error. The failed case also hung on main. Refs #918
The second review found that a7b4dc4's race abandoned a checkpoint the service accepted. A termination raised by background handler code while the oversized result was in flight won that race, and the service then accepted the result anyway, so the execution was recorded SUCCEEDED while the invocation answered FAILED. main answered SUCCEEDED. The manager now reports whether it sent that update, through checkpointExecutionResult, rather than the invocation inferring it from a termination. clearQueue and the failed-batch path tell the callers that asked to hear it that their update was never sent, so nothing waits on a promise another path has to settle. An accepted update reports SUCCEEDED whatever else terminated; an abandoned one answers from the termination. Four further answers disagreed depending on which exit was taken. One helper now decides them all, in a fixed order: a withdrawn token answers PENDING, a checkpoint failure throws its classified error, a serdes failure throws SerdesFailedError, and every other reason follows its class. - A failed checkpoint after the handler returned answered SUCCEEDED for a small result and threw for an oversized one. Both now throw. - A serdes failure on the oversized path threw a plain Error, so the execution failed for good instead of the invocation being retried. - A fault termination followed by a withdrawn token answered FAILED. - An unrecoverable invocation error was rethrown after the token was withdrawn, so Lambda retried with a token the service had already rejected. A PENDING that follows a handler error now carries that error to plugins. Refs #918
hln33
approved these changes
Sep 28, 2026
yaythomas
approved these changes
Sep 28, 2026
yaythomas
left a comment
Contributor
There was a problem hiding this comment.
A pauseExecution() call made while resumeExecution() is in flight resolves without pausing the execution.
If the checkpoint server rejects a pause request, every later pauseExecution() and resumeExecution() call rejects. run() then does not settle.
tsc now reads the catalog type from examples-catalog.d.json.ts. So tsc no longer checks the generated catalog against ExamplesWithConfig.
The three remaining open items will lose their home when this merges. Could you open a tracking issue for each and link them here pls?\
item 2 is the most pressing, because a rejected pause request turns into a hang in run(), and the fix looks small (reset paused on failure and catch the rejection on the pausing chain).
ParidelPooya
marked this pull request as ready for review
September 28, 2026 20:27
ParidelPooya
had a problem deploying
to
ai-pr-review-runtime
September 28, 2026 20:27 — with
GitHub Actions
Failure
hln33
pushed a commit
that referenced
this pull request
Sep 29, 2026
\#931 is merged, and both packages sit at versions that npm already serves: the core SDK at 2.4.0 and the testing SDK at 1.1.4. Both are also already tagged. So neither package can be released until its version moves. This bumps: - `@aws/durable-execution-sdk-js` from 2.4.0 to 2.5.0. - `@aws/durable-execution-sdk-js-testing` from 1.1.4 to 1.2.0. Both bumps are minor. 1. The core SDK changes how an invocation answers when the service withdraws its checkpoint token. It answers `PENDING` where it previously reported an outcome the service refused to record, and it no longer runs until the Lambda timeout when an oversized result is never sent. #918 tracks that as a feature, and no published API was removed or renamed. 2. The testing SDK adds `pauseExecution()` and `resumeExecution()` to `LocalDurableTestRunner`. Both are new public API, marked `@experimental`. The testing SDK's peer range on the core moves from `>=1.0.1 <=2.4.0` to `>=1.0.1 <=2.5.0`. That change is required, not incidental: `src/__tests__/peer-compatibility.test.ts` asserts that the core SDK's current version satisfies the declared range. I reverted just the range and the test failed, so leaving it would turn CI red. Every earlier core bump raised this bound the same way. No other package needs a change. The OTel plugin declares `>=2.4.0` and the Insight plugin `>=2.1.0`, and 2.5.0 satisfies both.
hln33
added a commit
to aws/aws-durable-execution-sdk-java
that referenced
this pull request
Oct 6, 2026
When a checkpoint response omits its token, the current invocation must stop checkpointing. CheckpointManager previously kept the spent token, so a later call failed and the invocation reported FAILED. It now ends PENDING. - Latch the missing token and prevent further checkpoint and paginated state calls. - Exempt a batch carrying the execution's terminal update because that execution is already complete. - Complete the accepted batch's waiters with SuspendExecutionException so user code cannot continue with results that cannot be recorded. - Consult the latch before every terminal DurableExecutor exit and when handling oversized results. - Describe only the SDK-observed condition and resulting behavior in comments, logs, and exception messages. - Cover completion versus withdrawal, no further API calls, plugins, oversized results, and step, wait, callback, map, and parallel paths. Ports aws/aws-durable-execution-sdk-js#931. Fixes #706
hln33
added a commit
to aws/aws-durable-execution-sdk-java
that referenced
this pull request
Oct 6, 2026
A checkpoint response without a checkpoint token means the invocation may checkpoint no further, so it reports PENDING. Tests had no way to produce that response against the in-memory checkpoint client. - LocalMemoryExecutionClient.pause() answers every later checkpoint without a token until resume(). Those checkpoints are still recorded, so the next invocation replays the work the SDK had not yet sent. - LocalDurableTestRunner exposes pauseExecution() and resumeExecution(), both marked @experimental. - runUntilComplete stops once a paused invocation reports PENDING. Without that check it advances operations and re-invokes, running handler work that no checkpoint could record. - CloudDurableTestRunner throws from both methods, because withholding a token is a behaviour of the in-memory client. Ports the testing half of aws/aws-durable-execution-sdk-js#931.
6 tasks
hln33
added a commit
to aws/aws-durable-execution-sdk-rust
that referenced
this pull request
Oct 6, 2026
A checkpoint response without a token means the current invocation must stop checkpointing. The client previously flattened the absent token to an empty string and classified it as a non-retryable failure, causing the invocation to report a fault. It now reports PENDING. - Classify token withdrawal separately from invocation and execution failures, and latch the missing token so no checkpoint or paginated state request can reuse it. - Track per-chunk acceptance of the execution's terminal SUCCEED update explicitly across a batch's sealed chunks: a chunk that carries and gets the terminal update accepted keeps the execution's success even if a later chunk in the same batch loses its token, matching the JS reference's single-chunk exemption with parity for Rust's chunked batch splitting. - Route checkpoint failures from all operation types and final buffered writes through the existing suspension path without recording a fatal error. - Describe only the SDK-observed condition and resulting behavior in comments, logs, and error messages. - Add LocalRunner token-withdrawal injection and end-to-end coverage for late withdrawal, terminal completion, latching, retained flush failures, and cross-chunk terminal-acceptance tracking. Keep the injection logic below clippy's function-size limit. Ports aws/aws-durable-execution-sdk-js#931. Fixes #67
hln33
added a commit
to aws/aws-durable-execution-sdk-java
that referenced
this pull request
Oct 7, 2026
When a checkpoint response omits its token, the current invocation must stop checkpointing. CheckpointManager previously kept the spent token, so a later call failed and the invocation reported FAILED. It now ends PENDING. - Latch the missing token and prevent further checkpoint and paginated state calls. - Exempt a batch carrying the execution's terminal update because that execution is already complete. - Complete the accepted batch's waiters with SuspendExecutionException so user code cannot continue with results that cannot be recorded. - Consult the latch before every terminal DurableExecutor exit and when handling oversized results. - Describe only the SDK-observed condition and resulting behavior in comments, logs, and exception messages. - Cover completion versus withdrawal, no further API calls, plugins, oversized results, and step, wait, callback, map, and parallel paths. Ports aws/aws-durable-execution-sdk-js#931. Fixes #706
hln33
added a commit
to aws/aws-durable-execution-sdk-java
that referenced
this pull request
Oct 7, 2026
A checkpoint response without a checkpoint token means the invocation may checkpoint no further, so it reports PENDING. Tests had no way to produce that response against the in-memory checkpoint client. - LocalMemoryExecutionClient.pause() answers every later checkpoint without a token until resume(). Those checkpoints are still recorded, so the next invocation replays the work the SDK had not yet sent. - LocalDurableTestRunner exposes pauseExecution() and resumeExecution(), both marked @experimental. - runUntilComplete stops once a paused invocation reports PENDING. Without that check it advances operations and re-invokes, running handler work that no checkpoint could record. - CloudDurableTestRunner throws from both methods, because withholding a token is a behaviour of the in-memory client. Ports the testing half of aws/aws-durable-execution-sdk-js#931.
This was referenced Oct 25, 2026
This branch had an error being 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.
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
Issue Link, if available
Closes #918.
Description
SDK: suspend when a checkpoint response has no token
InvalidParameterValueException: Invalid checkpoint token. Since fix(sdk): classify stale checkpoint token as invocation failure #920, the SDK classifies that error as an invocation error.After this change, the checkpoint manager does the following when a response has no token:
EXECUTION_SUSPENDED_BY_SERVICE. That reason is classified as a suspend. SowithDurableExecutionreturns{ Status: "PENDING" }through the existing suspend path.NewExecutionStatefrom that response. It does not resolve the callers of the accepted checkpoint. Either action would let the handler continue after the service has stopped recording. For example, the handler would start its next step. That step would run once in this invocation and again in the next one.The checkpoint that received the token-less response was accepted. So its updates are durable. Updates that the SDK had not yet sent are lost. They replay on the next invocation.
One case is exempt:
EXECUTIONupdate after the handler returns.EXECUTIONupdate returns normally, and the invocation reportsSUCCEEDED.SDK: a token withdrawn after the handler returns
maporparallelwith early completion are one example.SUCCEEDEDwith the result, orFAILED.mainhas the same behaviour.withDurableExecutionnow checks for a withdrawn token before it returnsSUCCEEDEDorFAILED. If the token was withdrawn, it returnsPENDING. The next invocation replays and reaches the outcome again.The oversized-result checkpoint had a related defect:
withDurableExecutionawaits that checkpoint after it has already chosen between the handler result and a termination. So a termination could not end that await.mainhas the same behaviour for the failed case.SUCCEEDED, even if a termination arrived while it was in flight, because the execution is then recorded as finished. An update it never sent answers from the termination: a withdrawn token givesPENDING, and a failed checkpoint throws its classified error.SDK: one place decides the answer
Four answers disagreed depending on which exit the invocation took. One helper now decides them all, in a fixed order: a withdrawn token answers
PENDING, a checkpoint failure throws its classified error, a serdes failure throwsSerdesFailedError, and every other reason follows its class.SUCCEEDEDfor a small result and threw for an oversized one. Both now throw.Error, so the execution failed for good instead of the invocation being retried.FAILED. It now answersPENDING.PENDING.A
PENDINGthat follows a handler error now carries that error to plugins.Testing SDK: pause and resume (experimental)
LocalDurableTestRunnergainspauseExecution()andresumeExecution(). Both are marked@experimental.pauseExecution()marks the execution paused on the local checkpoint server. The server then accepts each checkpoint and returns it without a token. So the running invocation suspends with PENDING at its next checkpoint.pauseExecution()resolves once no invocation is running.resumeExecution()clears the paused state. It then starts the deferred invocation, if one exists.Both methods are also on the
DurableTestRunnerinterface. So a test written against the interface compiles for either runner.CloudDurableTestRunnerrejects both methods with a not-implemented error. It sends no request to the service, because the service has no API to withhold checkpoint tokens.Adding the methods to the interface is a compile-time break for any external class that implements
DurableTestRunner. Such a class must add both methods. The two runners in this repository are the only known implementations.Examples
src/examples/pause-resume, shows both methods. Its config setslocalOnly: true. So the example is left out of the SAM template, and its tests are skipped underNODE_ENV=integration.tsc --noEmitin the examples package failed in any checkout wherenpm run generate-exampleshad not run. The cause is thatexamples-catalog.tsimports the generated, gitignoredexamples-catalog.json. A new declaration file,examples-catalog.d.json.ts, now givestscthe types throughallowArbitraryExtensions. The TypeScript Rollup plugin resolves the import to that declaration instead of the JSON. Sorollup.config.mjsresolves the import to the real JSON before that plugin runs.Open items from review
This pull request is a draft. An independent review found the items below. None of them blocks the SDK fix.
Review pass 1 found
SUCCEEDEDafter a withdrawn token and an oversized result that hung. Commit a7b4dc4 fixes both. Review pass 2 found that a7b4dc4's own race abandoned a checkpoint the service accepted, plus four answers that disagreed between exits. Commit 0bb808b fixes those.pauseExecution()call made whileresumeExecution()is in flight resolves without pausing the execution.pauseExecution()andresumeExecution()call rejects.run()then does not settle.tscnow reads the catalog type fromexamples-catalog.d.json.ts. Sotscno longer checks the generated catalog againstExamplesWithConfig.Conformance
AGENTS.mdplaces conformance tests in a separate repository. This behaviour warrants a conformance requirement: a checkpoint response without a token ends the invocation with PENDING, and the SDK makes no further checkpoint call.Merge
Please squash-merge. The first commit adds a setup option,
withholdCheckpointTokenOnCall. A later commit removes it and addspauseExecution()andresumeExecution()instead.Demo/Screenshots
Not applicable.
Checklist
Testing
Unit Tests
Have unit tests been written for these changes?
Yes.
checkpoint-termination.test.tschecks the termination reason and the warning. It checks that the manager makes no further call, including for a forced checkpoint. It checks that the caller of the accepted checkpoint stays unresolved.CloudDurableTestRunnertest checks that both methods reject and send no request.Integration Tests
Have integration tests been written for these changes?
Yes.
checkpoint-token-revoked.composed.test.tsruns throughwithDurableExecution. It checks the PENDING result. It checks that the spent token is never sent again. It checks that accepted checkpoints are kept and that the next step does not start. It checks that an oversized result still reportsSUCCEEDED. It checks that a token withdrawn after the handler returns or throws givesPENDING, including for an oversized result queued behind the withdrawn batch. It checks that an oversized result the service accepted reportsSUCCEEDEDeven when a termination arrived while it was in flight. It checks that a fault termination followed by a withdrawn token answersPENDING.checkpoint-failure.composed.test.tschecks that a failed oversized-result checkpoint throws its classified error instead of waiting until the Lambda timeout. It checks that a failed checkpoint and a serdes failure give the same answer whatever the result size.with-durable-execution.plugin.test.tschecks that aPENDINGcarrying an error reports it to plugins.checkpoint-composed.test.tshad a mock response with the fieldcheckpointTokeninstead ofCheckpointToken.mainignored a missing token, so the typo had no effect there. With this change, those tests waited for a checkpoint that never resolved. The mock now uses the field name of the real response.pause-resume-execution.integration.test.tsandpause-resume-execution-skip-time.integration.test.tsrun against the local checkpoint server. They cover a pause during a step, a deferred callback invocation, a deferred wait invocation withskipTime, repeated calls, and the error when no run is in progress.Results:
main. 2 Vitest tests passed.npm run lintreports no errors.tsc --noEmitpasses in all three packages.Examples
Has a new example been added for the change? (if applicable)
Yes.
src/examples/pause-resumehas two tests, each with a history file. One test pauses during a step. The other pauses while the workflow waits for a callback.