Skip to content

Refuse a workflow cancelling, resuming or deleting itself - #661

Merged
devhawk merged 3 commits into
mainfrom
refuse-direct-self-target
Nov 12, 2026
Merged

devhawk merged 3 commits into
mainfrom
refuse-direct-self-target

Conversation

@devhawk

@devhawk devhawk commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator

Called from inside a workflow, or from one of its steps, cancelWorkflow(s), resumeWorkflow(s) and deleteWorkflow(s) now throw IllegalArgumentException when their targets include the calling workflow. Nothing is written, and the workflow can catch the exception and carry on.

Why

Each of these clears or removes the caller's own workflow_status row, so under the ownership fence the caller's next checkpoint fails:

  • a self-cancel ended CANCELLED, with its cancel step never recorded;
  • a self-delete ended with the workflow gone;
  • a self-resume put the workflow back on the queue before its own step was recorded. Each dispatch replayed to the same resume and did it again, with no end short of deleting the workflow.

How

The check runs first inside the call's internal step, before any database call:

  • From a workflow body, the refusal is recorded as the error of DBOS.cancelWorkflow / DBOS.resumeWorkflow / DBOS.deleteWorkflow, so a recovery or fork rethrows it instead of deciding again.
  • From a step body, the call takes no step of its own, and the refusal is thrown into the enclosing step.

Calls with no workflow context (outside a workflow, DBOSClient, Conductor, the admin server) are unaffected, as are calls that target other workflows, including the caller's own children's trees.

This PR refuses only a caller that names itself. Refusing a cancel or delete of an ancestor's tree that contains the caller (cancelChildren / deleteChildren) needs the cascade and its step record in one transaction, and comes with #656.

Tests

  • New SelfTargetTest covers:
    • self-cancel, self-resume and self-delete, from a workflow body and from a step;
    • a bulk cancel naming the caller, which leaves the other targets untouched;
    • replay of a recorded refusal through a fork;
    • an uncaught refusal becoming the step's error;
    • cancelling another workflow and one's own child's tree, which still work.
  • OwnershipFenceTest: removes the three tests that recorded the old self-cancel, self-delete and self-resume behaviour.

🤖 Generated with Claude Code

Called from a workflow, or from one of its steps, a cancel, resume or
delete whose targets include the calling workflow now throws
IllegalArgumentException before anything is written. The workflow can
catch it and carry on.

Each of these clears or removes the caller's own row, so under the
ownership fence the caller's next checkpoint fails. A self-cancel ended
CANCELLED with no step recorded, a self-delete ended with the workflow
gone, and a self-resume put the workflow back on the queue before its
own step was recorded: each dispatch replayed to the same resume and did
it again, with no end short of deleting the workflow.

The check runs inside the call's step, so from a workflow body the
refusal is recorded as that step's error and a replay or fork throws it
again rather than deciding afresh. From a step body the call takes no
step of its own, and the refusal is thrown into the enclosing step.

This covers a caller that names itself. Refusing a cancel or delete of a
tree the caller belongs to (an ancestor with cancelChildren or
deleteChildren) needs the cascade and its step record in one
transaction, and comes with that change.
Assert that a refusal inside a step records no cancel step of its own,
name the step test for all three operations it covers, inline the
one-use launch helper, and release blocked workflows in a finally so a
failed assertion doesn't leave them parked until shutdown.
@devhawk
devhawk requested review from kraftp, maxdml and qianl15 and a balanced review from Copilot November 12, 2026 18:42

Copilot AI 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.

🟡 Changes recommended

Workflow-body refusal errors can lose their exception type during replay with a type-losing serializer, causing workflows that initially handle the error to fail on recovery or fork.

1 open issue
What changed in this PR

This PR prevents workflows from explicitly cancelling, resuming, or deleting themselves, including calls made inside their steps. Management calls targeting other workflows remain unchanged; ancestor-tree protection is deferred to #656.

Changes:

  • Adds self-target checks inside internal management steps, before target mutations.
  • Documents the new IllegalArgumentException behavior across public API overloads.
  • Adds refusal and replay tests, replacing tests for the previous self-target behavior.
File Description
transact/​src/​test/​java/​dev/​dbos/​transact/​workflow/​SelfTargetTest.java Tests refusals, replay, bulk-call safety, and allowed operations.
transact/​src/​test/​java/​dev/​dbos/​transact/​workflow/​OwnershipFenceTest.java Removes tests and helpers for the previous self-target behavior.
transact/​src/​main/​java/​dev/​dbos/​transact/​execution/​DBOSExecutor.java Adds shared self-target validation for cancel, resume, and delete.
transact/​src/​main/​java/​dev/​dbos/​transact/​DBOS.java Documents self-target exceptions on public management methods.

Review effort: Balanced

A built-in step recorded what it threw with the configured serializer,
so a custom serializer that doesn't keep exception types rebuilt a
different type on replay. A workflow that caught a refused self-cancel's
IllegalArgumentException on its first run would then fail when
recovered or forked, instead of taking the same branch.

DBOS's own steps now record their errors natively whatever serializer is
configured, as a child start's refusal already is. DBOS.getResult keeps
the configured serializer: what it records is the awaited workflow's
own error, which belongs to the application. User steps are unchanged,
and replay reads the recorded format, so it needs no change.
@devhawk
devhawk merged commit 6847498 into main Nov 12, 2026
11 checks passed
@devhawk
devhawk deleted the refuse-direct-self-target branch November 12, 2026 20:29
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.

3 participants