Skip to content

Add explicit cancellation (preview) - #264

Merged
igalshilman merged 2 commits into
mainfrom
explicit-cancellation
Nov 4, 2026
Merged

igalshilman merged 2 commits into
mainfrom
explicit-cancellation

Conversation

@igalshilman

@igalshilman igalshilman commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Explicit cancellation (preview)

By default, when Restate cancels an invocation, the cancellation fails whatever the handler awaits next, and the calls it made are cancelled with it. With explicit cancellation, neither happens. The handler finds out by awaiting cancellation(ctx), and decides what to stop and how to finish:

from restate._preview import cancel_previous_calls, cancellation

@greeter.handler(explicit_cancellation=True)
async def greet(ctx: Context, name: str) -> str:
    done, _ = await restate.select(work=ctx.service_call(slow_greeting, arg=name), cancelled=cancellation(ctx))
    if done == "cancelled":
        await cancel_previous_calls(ctx)   # the calls still in flight; returns their invocation ids
        raise TerminalError("cancelled", status_code=409)
    ...

This is useful when cancellation must interrupt work that isn't a Restate operation (an HTTP request to a model API, say), or must leave state consistent before the handler ends. It mirrors the TypeScript SDK's explicitCancellation.

Changes

  • explicit_cancellation=True, a key like the other options:
    • On the handler decorators: handler(), and a workflow's main().
    • On the Service, VirtualObject and Workflow constructors, for all their handlers.
    • Off by default, so existing handlers behave exactly as before.
  • restate._preview:
    • cancellation(ctx) returns a future that completes when the invocation is cancelled.
    • cancel_previous_calls(ctx) cancels the attempt's calls that are still in flight.
  • src/lib.rs: PyVM(..., explicit_cancellation=) disables shared-core's implicit cancellation.
  • ServerInvocationContext:
    • Keeps the attempt's calls that may still be in flight, only when the option is on, pruning completed calls as new ones are made.
    • Caches the cancellation future, since the cancel notification can be taken only once.

The key is marked as preview in its docstring. A follow-up PR gives explicit_recovery the same key form, keeping its decorator as a deprecated alias, so the two preview options are set the same way.

Tests

tests/explicit_cancellation.py:

  • Configuration: off by default; a key of a whole service, of an object, or of one handler (including a workflow's main() and its shared handlers).
  • Against a Restate server:
    • A cancelled handler goes on and cancels its own call (the call ends with 409, the handler answers 200).
    • Calls that completed are not cancelled.
    • Without the option, the cancellation fails the handler (409).
    • Without the option, cancellation(ctx) is refused with a terminal error.

This PR is a prerequisite of #262 (session objects and agents), whose agents use it to stop a turn, including a model request in flight.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy

With explicit cancellation, Restate cancelling an invocation neither fails
the handler's awaits nor cancels the calls it made: the handler finds out
by awaiting cancellation(ctx), and decides what to stop.

    @explicit_cancellation
    @greeter.handler()
    async def greet(ctx, name):
        done, _ = await restate.select(work=ctx.service_call(slow, arg=name), cancelled=cancellation(ctx))
        if done == "cancelled":
            await cancel_previous_calls(ctx)   # the calls still in flight
            raise TerminalError("cancelled", status_code=409)

In restate._preview, next to explicit_recovery: on a handler, or a whole
service, object or workflow. The VM disables shared-core's implicit
cancellation; the context keeps the attempt's calls that may be in flight
(only with the option on), which cancel_previous_calls cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

I reviewed the explicit cancellation preview end to end: the Python API, the context bookkeeping, the PyVM option wiring into shared core 8.3.0, and the integration tests.

  • Preview API: explicit_cancellation, cancellation(ctx) and cancel_previous_calls(ctx) in restate._preview. The decorator follows the same service/handler pattern as explicit_recovery.
  • VM option: PyVM(..., explicit_cancellation=True) maps to ImplicitCancellationOption::Disabled. Otherwise it keeps the shared-core default, so existing handlers behave exactly as before.
  • In-flight call tracking: create_call_future (the only call path, scoped calls included) records calls only when the option is on, and drops completed ones as new calls are made. One-way sends are not tracked, which matches the shared-core default cancel_children_one_way_calls: false.
  • Cancellation future: cancellation(ctx) is cached on the context, so taking the notification once is safe however many times the future is awaited. With implicit cancellation disabled, do_progress no longer adds CANCEL_NOTIFICATION_HANDLE itself, so nothing else consumes the notification.
  • Replay determinism: cancel_previous_calls decides what to cancel with is_completed(). I checked shared core 8.3.0: notifications are moved from to_process to ready one at a time, in journal order, and stop at the first resolution. So the set of calls it cancels, and the sys_cancel commands it journals, come out the same on replay.
  • Tests: these cover the configuration surface, cancelling a call after a cancellation, the case where completed calls leave nothing to cancel, and the implicit-mode behaviour, all against the Docker harness. ruff and pyright pass locally on the changed files.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Test Results

  8 files  ±0    8 suites  ±0   3m 48s ⏱️ -10s
 71 tests ±0   71 ✅ ±0  0 💤 ±0  0 ❌ ±0 
322 runs  ±0  322 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 5f2ab58. ± Comparison against base commit 5a63d97.

♻️ This comment has been updated with latest results.

Like every other option: @svc.handler(explicit_cancellation=True), a
workflow's main()/handler() too, and Service/VirtualObject/Workflow(...,
explicit_cancellation=True) for all their handlers. The
restate._preview.explicit_cancellation decorator is gone;
cancellation() and cancel_previous_calls() stay in restate._preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePHkTUvGceAzHBUPULRvy

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

Since the last review, explicit_cancellation has moved from a restate._preview decorator to a keyword option on the service constructors and handler decorators.

  • Removed the explicit_cancellation decorator: restate._preview now exports only cancellation(ctx) and cancel_previous_calls(ctx). Their docstrings and the TerminalError message now point to explicit_cancellation=True, and no references to the old decorator remain anywhere in the repo.
  • Added the explicit_cancellation key to the service constructors: Service, VirtualObject and Workflow store it in place of the hardcoded False. server.py still applies handler.explicit_cancellation or service.explicit_cancellation.
  • Added the key to the handler decorators: Service.handler, VirtualObject.handler, Workflow.main and Workflow.handler all pass it to make_handler by keyword, as its last parameter. Existing positional call sites keep their argument order, and those three files contain the only make_handler callers.
  • Updated the tests: the configuration test now covers the constructor key, a virtual object handler, and a workflow's main() and shared handler. A new on_one_handler service tests the handler-level key against the server. ruff, ruff format --check and pyright pass, and so do the configuration tests that don't need Docker.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@igalshilman
igalshilman merged commit 179fe3f into main Nov 4, 2026
11 checks passed
@igalshilman
igalshilman deleted the explicit-cancellation branch November 4, 2026 18:23
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant