Skip to content

Add explicit cooperative cancellation for durable workflow cleanup #483

Description

@rmcdaniel

Problem

Dogfooding a long-running workflow exposed a cleanup limitation: cancelling a waiting run does not resume its workflow code, so durable activities in finally are never scheduled. Applications that need graceful cancellation must currently build an application-specific signal path or rely entirely on external expiry/reconciliation.

This is an API capability gap, not a regression in 2.0.4. The current cancellation contract makes cancellation terminal. #480/#481 fixed ordinary Fiber disposal and correctly documented that terminal cancellation does not guarantee cleanup; that issue should remain closed.

Reproduction

Verified on published durable-workflow/workflow 2.0.4 (f7b910958a186a11f52e916843f9019ea7b85ad8), PHP 8.4.25, Laravel database queue with SQLite, 2026-09-06 UTC:

use Workflow\V2\Activity;
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\activity;
use function Workflow\V2\timer;

final class WaitingWorkflow extends Workflow
{
    public function handle(): void
    {
        try {
            timer(3600);
        } finally {
            activity(CleanupActivity::class);
        }
    }
}

final class CleanupActivity extends Activity
{
    // Test-only observation; real cleanup belongs in an idempotent activity.
    public static int $calls = 0;

    public function handle(): void
    {
        self::$calls++;
    }
}

$stub = WorkflowStub::make(WaitingWorkflow::class, 'cancel-cleanup');
$stub->start();
// Drain the database queue until the workflow is waiting on its timer.
$stub->cancel('stop');
// Drain again, including after the timer would have elapsed.

Observed: cancelled terminal run, zero cleanup calls. Positive control without cancellation: completed run, one cleanup call. The focused Laravel test passes these characterizations (1 test / 9 assertions); queue work runs in-process via Artisan, so the static counter is visible. This does not claim fresh-process cancellation recovery or service-mode validation.

The implementation explains the result: WorkflowStub::attemptCancel() closes the run and its pending tasks/timers; RunWorkflowTask does not replay terminal runs.

Accepted Direction

Provide an explicit cooperative cancellation path that can durably execute bounded cleanup before closing the run. Preserve the existing terminal cancel() semantics for stable consumers; decide the additive API and history representation before implementation. Forced termination and process death must never be advertised as guaranteed cleanup.

Acceptance

  • Cooperative cancellation is durable, idempotent, and observable while cleanup is pending; it is distinct from immediate terminal cancellation/termination.
  • A suspended workflow can observe the request and execute cleanup activities with normal retry/timer support and a bounded cancellation-safe scope.
  • Prove success, cleanup failure/retry, duplicate cancellation, termination during cleanup, worker loss and fresh-process replay; no duplicate irreversible effects.
  • Preserve existing terminal cancellation behavior and the Durable calls in finally fail when a suspended workflow Fiber is disposed #480 disposal regression coverage.
  • Document the supported cleanup pattern and the need for external resource leases/reconciliation even with cooperative cancellation.
  • Assess Server and PHP/Python/Rust SDK parity before claiming a portable service-mode capability. This reproduction establishes only the embedded behavior.

Accepted product improvement ahead of discretionary coverage expansion, after the current in-progress recovery safety change. No release date is implied by this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:P2Normal-priority product work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions