Skip to content

fix(sdk): suspend with PENDING when a checkpoint response has no token - #931

Merged
ParidelPooya merged 6 commits into
mainfrom
fix/pending-on-missing-checkpoint-token
Sep 28, 2026
Merged

ParidelPooya merged 6 commits into
mainfrom
fix/pending-on-missing-checkpoint-token

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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

  1. Each checkpoint response carries the token for the next checkpoint call.
  2. A response without a token means the service accepts no further checkpoints from this invocation. For example, a newer invocation has taken over the execution.
  3. Before this change, the checkpoint manager ignored the missing token. It kept the token it had just sent, and it sent that token again on the next call.
  4. The service rejects a reused token with InvalidParameterValueException: Invalid checkpoint token. Since fix(sdk): classify stale checkpoint token as invocation failure #920, the SDK classifies that error as an invocation error.
  5. So the invocation ended with a Lambda error one call later.

After this change, the checkpoint manager does the following when a response has no token:

  • It sends no further checkpoint calls. It removes every queued checkpoint without sending it.
  • It logs a warning through the customer logger. An absent token looks the same as a client that dropped the field. So the warning is the only record of which one happened.
  • It terminates with a new termination reason, EXECUTION_SUSPENDED_BY_SERVICE. That reason is classified as a suspend. So withDurableExecution returns { Status: "PENDING" } through the existing suspend path.
  • It does not apply NewExecutionState from 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:

  1. The SDK checkpoints a result larger than 6 MB as an EXECUTION update after the handler returns.
  2. That update finishes the execution.
  3. So a token-less response to a batch that contains an EXECUTION update returns normally, and the invocation reports SUCCEEDED.

SDK: a token withdrawn after the handler returns

  1. A handler can return, or throw, while a checkpoint it did not await is still in flight. The unfinished branches of a map or parallel with early completion are one example.
  2. If the service answers that checkpoint without a token, it will record nothing further from this invocation. That includes the invocation's own result.
  3. Before this change, the invocation still returned SUCCEEDED with the result, or FAILED. main has the same behaviour.
  4. So withDurableExecution now checks for a withdrawn token before it returns SUCCEEDED or FAILED. If the token was withdrawn, it returns PENDING. The next invocation replays and reaches the outcome again.

The oversized-result checkpoint had a related defect:

  1. withDurableExecution awaits that checkpoint after it has already chosen between the handler result and a termination. So a termination could not end that await.
  2. The checkpoint can wait in the queue behind another batch. If that batch fails, or is answered without a token, the checkpoint manager clears the queue. The result is never sent, and the await never settles.
  3. So the invocation ran until the Lambda timeout. main has the same behaviour for the failed case.
  4. The checkpoint manager now reports whether it sent that update. An update the service accepted reports 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 gives PENDING, 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 throws SerdesFailedError, and every other reason follows its class.

  • A checkpoint that failed 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-result 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. It now answers PENDING.
  • An unrecoverable invocation error was rethrown after the token was withdrawn, so Lambda retried with a token the service had already rejected. It now answers PENDING.

A PENDING that follows a handler error now carries that error to plugins.

Testing SDK: pause and resume (experimental)

LocalDurableTestRunner gains pauseExecution() and resumeExecution(). 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.
  • While the execution is paused, the runner starts no invocation. Waits still elapse. Callbacks can still be sent. The runner defers the invocations that they would start.
  • 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 DurableTestRunner interface. So a test written against the interface compiles for either runner. CloudDurableTestRunner rejects 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

  • A new example, src/examples/pause-resume, shows both methods. Its config sets localOnly: true. So the example is left out of the SAM template, and its tests are skipped under NODE_ENV=integration.
  • tsc --noEmit in the examples package failed in any checkout where npm run generate-examples had not run. The cause is that examples-catalog.ts imports the generated, gitignored examples-catalog.json. A new declaration file, examples-catalog.d.json.ts, now gives tsc the types through allowArbitraryExtensions. The TypeScript Rollup plugin resolves the import to that declaration instead of the JSON. So rollup.config.mjs resolves 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 SUCCEEDED after 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.

  1. A pauseExecution() call made while resumeExecution() is in flight resolves without pausing the execution.
  2. If the checkpoint server rejects a pause request, every later pauseExecution() and resumeExecution() call rejects. run() then does not settle.
  3. tsc now reads the catalog type from examples-catalog.d.json.ts. So tsc no longer checks the generated catalog against ExamplesWithConfig.

Conformance

AGENTS.md places 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 adds pauseExecution() and resumeExecution() instead.

Demo/Screenshots

Not applicable.

Checklist

  • I have filled out every section of the PR template
  • I have thoroughly tested this change

Testing

Unit Tests

Have unit tests been written for these changes?

Yes.

  • checkpoint-termination.test.ts checks 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.
  • The testing SDK tests check the server's pause and resume handlers and their dispatch. They check that the server omits the token while the execution is paused and keeps the updates.
  • A CloudDurableTestRunner test 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.ts runs through withDurableExecution. 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 reports SUCCEEDED. It checks that a token withdrawn after the handler returns or throws gives PENDING, including for an oversized result queued behind the withdrawn batch. It checks that an oversized result the service accepted reports SUCCEEDED even when a termination arrived while it was in flight. It checks that a fault termination followed by a withdrawn token answers PENDING.
  • checkpoint-failure.composed.test.ts checks 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.ts checks that a PENDING carrying an error reports it to plugins.
  • I reverted each fix in turn and confirmed that a test failed each time.
  • checkpoint-composed.test.ts had a mock response with the field checkpointToken instead of CheckpointToken. main ignored 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.ts and pause-resume-execution-skip-time.integration.test.ts run against the local checkpoint server. They cover a pause during a step, a deferred callback invocation, a deferred wait invocation with skipTime, repeated calls, and the error when no run is in progress.
  • I disabled each pause mechanism in the orchestrator in turn. At least one test failed each time.

Results:

  • Core SDK: 1268 tests passed.
  • Testing SDK: 1189 tests passed, 3 skipped.
  • Examples: 187 Jest tests passed, 1 skipped. The skipped suite was already skipped on main. 2 Vitest tests passed.
  • npm run lint reports no errors. tsc --noEmit passes in all three packages.

Examples

Has a new example been added for the change? (if applicable)

Yes. src/examples/pause-resume has two tests, each with a history file. One test pauses during a step. The other pauses while the workflow waits for a callback.

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

@yaythomas yaythomas 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.

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
ParidelPooya marked this pull request as ready for review September 28, 2026 20:27
@ParidelPooya
ParidelPooya merged commit 29d06f9 into main Sep 28, 2026
36 of 37 checks passed
@ParidelPooya
ParidelPooya deleted the fix/pending-on-missing-checkpoint-token branch September 28, 2026 20:27
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.
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 branch had an error being deployed

1 failed deployment
ai-pr-review-runtime — 0bb808b8 Deployed Sep 28, 2026 by ParidelPooya via notify / Generate notification summary #214
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.

[Feature]: Exit gracefully with PENDING when a checkpoint response has no CheckpointToken

3 participants