Skip to content

feat: interrupt the running action when a workflow is cancelled (opt-in) - #31

Draft
sndre wants to merge 1 commit into
mainfrom
feat/inflight-cancellation-interrupt
Draft

sndre wants to merge 1 commit into
mainfrom
feat/inflight-cancellation-interrupt

Conversation

@sndre

@sndre sndre commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Layer 2 of in-flight cancellation: with INFLIGHT_CANCELLATION_INTERRUPT on (and INFLIGHT_CANCELLATION_CHECKPOINTS, which it requires), cancelWorkflow interrupts the thread running the cancelled workflow's action in this process, so a blocking action stops instead of running to completion.

Mechanism. ActionExecutor registers the executing thread per workflow id (InFlightActions) for the duration of a synchronous action. cancelWorkflow interrupts every registration for that workflow after persisting CANCELLED. An action that fails while an interrupt was delivered ends as WorkflowCancelledException: no checkpoint, no iteration increment, no retry. One that completes anyway is checkpointed normally, counted as inflightCancellationIgnored, and Layer 1's boundary check stops the next action. Per-registration locking keeps an interrupt from landing on a thread that has already left its action, so no pooled thread carries a stray interrupt flag.

before after (flag on)
cancel while an action blocks action runs to completion, then Layer 1 stops the next one action is interrupted now; workflow settles CANCELLED
flag off unchanged unchanged

Two deviations from the founding record, stated here on purpose. The record assumed reusing the lease renewer's taskFuture.cancel(true); that future belongs to the scheduler thread blocked in handleTask(...).get(), not to the task-handler thread running the action, so it cannot reach the action and this change interrupts the action thread directly. Consequences: delivery no longer depends on AUTOMATIC_LEASE_RENEWAL, and it is same-process only — a cancel taken by another worker delivers no interrupt and the action is stopped at its next boundary by Layer 1. The opt-in surface is a per-embedder flag, not per-workflow.

Not covered: Kotlin suspend actions and future-returning actions are not registered, a cancel landing before an action registers is caught by the boundary check instead, and an action that swallows the interrupt completes (counted as inflightCancellationIgnored).

Tests. InFlightActionsTest (delivery, two concurrent actions of one workflow, confinement to the cancelled workflow, a 2,000-iteration race between exit and interrupt), ActionExecutorTest (interrupted action ends cancelled with nothing checkpointed; a swallowing action completes with a clear flag), SkipperEngineTest (interrupt with both flags on; none with either off), and an SQLite integ test where a 20 s blocking action is cut short by cancel and the caller sees CancelledWorkflow.

Follow-up (pre-existing, not changed here). A signal handler's action that ends in WorkflowCancelledException still goes through signalWorkflow's success path, which writes RUNNING and reschedules; only the store's version check stops a cancelled workflow from being resurrected. Same hole is reachable from Layer 1's boundary check on main; needs its own settle guard.

Evaluators. Three rounds of Argus / Subtraction / Test-leak; final round clean on all three. Round 1 Argus: exit/interrupt race, one entry per workflow id, untested opt-in — fixed (per-registration lock, list per workflow, flag-off test). Round 2 Argus: confinement to the cancelled workflow and the swallowing-action path untested — fixed with tests. Round 3 non-blocking, handled: registration now skipped unless the flag is on (no per-action cost for non-adopters); the flag's KDoc states the interrupt lands in arbitrary action code and can close an interruptible channel or poison a pooled connection. Kept, per Subtraction: the interrupted-action mapping to WorkflowCancelledException (it is what prevents a transient checkpoint and a retry of the interrupted action).

🤖 Generated with Claude Code

request.proxyMethod.invoke(request.actionObject)
}
} finally {
interruptedByCancel = registration != null && inFlightActions.exit(registration)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we need to evaluate wether the action is async and exit the inFlightAction only after the async execution completed rather than synchronously here, otherwise we'll exit immeidately and the action might still be executing async?

* a thread that has already left its action, so a thread never carries an unreported interrupt out.
*/
@Singleton
class InFlightActions {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious, did you consider doing the cancellation at the workflow level rather than the action level? is this to allow graceful handling of the action cancellation at the workflow layer? otherwise at the workflow level we can reuse the existing workflows in flight registry we currently use for lease renewal

Behind INFLIGHT_CANCELLATION_INTERRUPT (which also requires
INFLIGHT_CANCELLATION_CHECKPOINTS), cancelWorkflow interrupts the thread
running the cancelled workflow's action in this process. ActionExecutor
registers the executing thread per workflow for the duration of a
synchronous action; an action that fails from the interrupt ends the
workflow as cancelled with no checkpoint, no iteration increment and no
retry, while one that completes anyway is checkpointed normally and the
boundary check stops the next action.

The founding assumed the lease renewer's taskFuture.cancel(true) could be
reused; that future is the scheduler thread's, blocked waiting on the
execution, so cancelling it never reached the action. Delivery is therefore
same-process only: a cancel taken by another worker is observed at the next
action boundary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sndre
sndre force-pushed the feat/inflight-cancellation-interrupt branch from db94a58 to 9c5fb33 Compare September 30, 2026 18:58

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.

2 participants