From 0d89e9c04826614224c2c5e4a80828c2bee14528 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:39:20 +0000 Subject: [PATCH 1/3] Read the timeout cleanup cause on the cancellation context Workflow's timeout cleanup (workflow#863, implementation draft workflow#1129) adds an optional top-level `cause` to the portable cancellation context. `timeout` marks a timeout-cleanup request: the run passed its work deadline and is now in cleanup. Absent or `cancellation` is an ordinary cancellation, and any other value is refused. CancellationContext reads `cause` and, for a timeout, the optional `timeout_kind` (execution or run) and `work_deadline_at`. Those fields are refused without the timeout cause. toArray() writes them back as read, so an ordinary context stays byte-identical. A scoped root built from a timeout run context keeps its cause, as Core's does. A timeout delivery raises the new experimental WorkflowTimeoutCleanupRequested, which extends WorkflowCancelled, so existing catch (WorkflowCancelled) compensation runs on timeout too. WorkflowCancelled is no longer final so the subtype can extend it. Core's CancellationContextCauseTest vectors are ported (ordinary, explicit cancellation, timeout, invalid causes and timeout fields, descendant without cause, round-trip), along with replay tests and a replay-regression fixture for the delivered subtype. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 ++ docs/cooperative-cancellation-design.md | 20 ++ src/Exception/WorkflowCancelled.php | 23 +- .../WorkflowTimeoutCleanupRequested.php | 30 ++ src/Worker/CancellationContext.php | 70 +++++ src/Worker/ScopedCancellationContext.php | 3 + src/Worker/WorkflowContext.php | 7 +- tests/CancellationContextCauseTest.php | 279 ++++++++++++++++++ .../timeout-cleanup-cancellation-cause.json | 38 +++ 9 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 src/Exception/WorkflowTimeoutCleanupRequested.php create mode 100644 tests/CancellationContextCauseTest.php create mode 100644 tests/fixtures/replay-regressions/timeout-cleanup-cancellation-cause.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 91073c07..549da843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Timeout cleanup cause on the cancellation context (workflow#863, + workflow#1129). `CancellationContext` reads the optional top-level `cause` + of `durable-workflow.cancellation-context/v1` and `v2`: `timeout` for a + deadline-triggered timeout cleanup request, absent or `cancellation` for an + ordinary cancellation. Any other value is refused as invalid. A timeout + context may also carry `timeout_kind` (`execution` or `run`) and + `work_deadline_at`, refused without the timeout cause. `$cause`, + `isTimeout()`, `$timeoutKind` and `workDeadlineAt()` read them, and + `toArray()` writes them back as read; an ordinary context stays + byte-identical. A timeout delivery raises the new experimental + `WorkflowTimeoutCleanupRequested`, a subclass of `WorkflowCancelled`, so + existing `catch (WorkflowCancelled)` handlers also run on timeout cleanup. + `WorkflowCancelled::forContext()` picks the class for a delivered context. + +### Changed + +- `WorkflowCancelled` is no longer `final`, so `WorkflowTimeoutCleanupRequested` + can extend it. + ## [3.17.0] - 2026-11-13 ### Added diff --git a/docs/cooperative-cancellation-design.md b/docs/cooperative-cancellation-design.md index dc2c543b..c96b8cb3 100644 --- a/docs/cooperative-cancellation-design.md +++ b/docs/cooperative-cancellation-design.md @@ -157,6 +157,26 @@ observations retain the opaque history refresh route while canonical history supplies the workflow-facing object. Older cancellation histories without the rich snapshot continue delivering cancellation with `context === null`. +## Timeout cleanup cause + +A context can carry an optional top-level `cause` (workflow#863). `timeout` +means deadline-triggered timeout cleanup requested the interruption: the run +passed its work deadline and is now in cleanup. Absent or `cancellation` means +an ordinary cooperative cancellation, and any other value is refused as +invalid. A timeout context may also name `timeout_kind` (`execution` or `run`) +and `work_deadline_at`; those fields are refused without the timeout cause. +Its cleanup deadline is the context's existing `cleanup_deadline_at`. A +descendant cancelled under the request is cancelled, not timed out, so it +carries no cause. `toArray()` writes `cause` and the timeout fields back as +read, and leaves an ordinary context unchanged. + +`CancellationContext::$cause`, `isTimeout()`, `$timeoutKind` and +`workDeadlineAt()` read them. A timeout delivery raises +`WorkflowTimeoutCleanupRequested`, which extends `WorkflowCancelled`, so +existing `catch (WorkflowCancelled)` compensation runs on timeout too. Test +`$cancelled->context?->isTimeout()` (the cross-SDK discriminator is `cause`) +when cleanup must differ. The run still ends timed out. + ## Child policies Child authoring accepts `CancellationPolicy` and `ParentClosePolicy` values from diff --git a/src/Exception/WorkflowCancelled.php b/src/Exception/WorkflowCancelled.php index 18e94bac..fa1efec3 100644 --- a/src/Exception/WorkflowCancelled.php +++ b/src/Exception/WorkflowCancelled.php @@ -7,7 +7,14 @@ use DurableWorkflow\Worker\CancellationContext; use DurableWorkflow\Worker\ScopedCancellationContext; -final class WorkflowCancelled extends DurableWorkflowException +/** + * A cooperative cancellation request delivered at an authored call, or a + * workflow result that closed cancelled. + * + * Timeout cleanup delivers the {@see WorkflowTimeoutCleanupRequested} + * subtype, so handlers that catch this class also run on timeout cleanup. + */ +class WorkflowCancelled extends DurableWorkflowException { public function __construct( string $message = '', @@ -18,4 +25,18 @@ public function __construct( ) { parent::__construct($message, $code, $previous); } + + /** + * The exception for a delivered request: the timeout cleanup subtype when + * its context's `cause` is `timeout`. + */ + public static function forContext( + ?string $requestId, + CancellationContext|ScopedCancellationContext|null $context, + string $message = 'Workflow cancellation was requested.', + ): self { + return $context instanceof CancellationContext && $context->isTimeout() + ? new WorkflowTimeoutCleanupRequested(requestId: $requestId, context: $context) + : new self($message, requestId: $requestId, context: $context); + } } diff --git a/src/Exception/WorkflowTimeoutCleanupRequested.php b/src/Exception/WorkflowTimeoutCleanupRequested.php new file mode 100644 index 00000000..1b7ff655 --- /dev/null +++ b/src/Exception/WorkflowTimeoutCleanupRequested.php @@ -0,0 +1,30 @@ +cause === self::CAUSE_TIMEOUT; + } + /** @param array $snapshot */ public static function fromArray(array $snapshot): self { @@ -106,6 +126,7 @@ public static function fromArray(array $snapshot): self if ($deadline <= $requestedAt) { throw new InvalidArgumentException('Cancellation deadline must follow the original request.'); } + [$cause, $timeoutKind, $workDeadline] = self::cause($snapshot, $deadline); if ($scopeOrigin !== null) { self::assertScopedOrigin($snapshot, $scopeOrigin, $normalized, $requester, $requestedAt, $deadline); } @@ -113,6 +134,7 @@ public static function fromArray(array $snapshot): self return new self( $requestId, $rootRequestId, $rootInstanceId, $rootRunId, $parentRequestId, $reason, $requester, self::text($snapshot, 'source'), $requestedAt, $deadline, $normalized, $scopeOrigin, + $cause, $timeoutKind, $workDeadline, ); } @@ -126,6 +148,12 @@ public function deadline(): DateTimeImmutable return $this->cleanupDeadline; } + /** The work deadline a timeout context names, if it names one. */ + public function workDeadlineAt(): ?DateTimeImmutable + { + return $this->workDeadline; + } + /** Remaining seconds at the consumed replay boundary, never the host clock. */ public function remaining(): float { @@ -168,6 +196,11 @@ public function toArray(): array 'requested_at' => $this->originalRequestedAt->format('Y-m-d\TH:i:s.u\Z'), 'cleanup_deadline_at' => $this->cleanupDeadline->format('Y-m-d\TH:i:s.u\Z'), 'lineage' => $this->lineage, + ...($this->cause === null ? [] : ['cause' => $this->cause]), + ...($this->timeoutKind === null ? [] : ['timeout_kind' => $this->timeoutKind]), + ...($this->workDeadline === null ? [] : [ + 'work_deadline_at' => $this->workDeadline->format('Y-m-d\TH:i:s.u\Z'), + ]), ...($this->scopeOrigin === null ? [] : [ 'scope_origin' => $this->scopeOrigin->toArray(), 'scope_authority_deadline_at' => $this->cleanupDeadline->format('Y-m-d\TH:i:s.u\Z'), @@ -225,6 +258,43 @@ private static function assertScopedOrigin( } } + /** + * Read the optional top-level `cause` and, for a timeout, its timeout + * fields. Unknown causes, and timeout fields without the timeout cause, + * are refused. + * + * @param array $snapshot + * @return array{?string, ?string, ?DateTimeImmutable} + */ + private static function cause(array $snapshot, DateTimeImmutable $deadline): array + { + $cause = $snapshot['cause'] ?? null; + if ($cause !== null && !in_array($cause, [self::CAUSE_CANCELLATION, self::CAUSE_TIMEOUT], true)) { + throw new InvalidArgumentException('Cancellation cause must be cancellation or timeout.'); + } + $timeoutKind = $snapshot['timeout_kind'] ?? null; + $workDeadlineAt = $snapshot['work_deadline_at'] ?? null; + if ($cause !== self::CAUSE_TIMEOUT) { + if ($timeoutKind !== null || $workDeadlineAt !== null) { + throw new InvalidArgumentException('Cancellation timeout fields require the timeout cause.'); + } + + return [$cause, null, null]; + } + if ($timeoutKind !== null && !in_array($timeoutKind, ['execution', 'run'], true)) { + throw new InvalidArgumentException('Cancellation timeout_kind must be execution or run.'); + } + $workDeadline = null; + if ($workDeadlineAt !== null) { + $workDeadline = self::timestamp(self::text($snapshot, 'work_deadline_at')); + if ($workDeadline >= $deadline) { + throw new InvalidArgumentException('Cancellation work deadline must precede its cleanup deadline.'); + } + } + + return [$cause, $timeoutKind, $workDeadline]; + } + /** @param array $value */ private static function text(array $value, string $key): string { diff --git a/src/Worker/ScopedCancellationContext.php b/src/Worker/ScopedCancellationContext.php index 8d51a9fd..8f6b7430 100644 --- a/src/Worker/ScopedCancellationContext.php +++ b/src/Worker/ScopedCancellationContext.php @@ -111,6 +111,9 @@ public static function fromArray(array $snapshot): self } $deadlineSnapshot = $root->toArray(); $deadlineSnapshot['cleanup_deadline_at'] = $entry['cleanup_deadline_at']; + // Only the budget is checked here; a narrowed scope budget is not + // compared with a timeout root's work deadline. + unset($deadlineSnapshot['timeout_kind'], $deadlineSnapshot['work_deadline_at']); $nextDeadline = CancellationContext::fromArray($deadlineSnapshot)->deadline(); if (($index === 0 && $nextDeadline != $root->deadline()) || $nextDeadline > $deadline) { throw new InvalidArgumentException('Scoped cancellation cannot change its root or extend a descendant budget.'); diff --git a/src/Worker/WorkflowContext.php b/src/Worker/WorkflowContext.php index f41754fa..efc0abac 100644 --- a/src/Worker/WorkflowContext.php +++ b/src/Worker/WorkflowContext.php @@ -757,10 +757,7 @@ public function throwIfCancellationRequested(): void { if ($this->isCancellationRequested() && !$this->isCancellationShielded()) { $context = $this->cancellationContext(); - throw new WorkflowCancelled( - 'Workflow cancellation was requested.', requestId: $context->requestId ?? $this->deliveredCancellationRequestId, - context: $context, - ); + throw WorkflowCancelled::forContext($context->requestId ?? $this->deliveredCancellationRequestId, $context); } } @@ -860,7 +857,7 @@ public function deliveredCancellation(string $requestId, CancellationContext|Sco $this->deliveredCancellationContext = $context; } - return new WorkflowCancelled('Workflow cancellation was requested.', requestId: $requestId, context: $context); + return WorkflowCancelled::forContext($requestId, $context); } /** @return Closure(): DateTimeImmutable */ diff --git a/tests/CancellationContextCauseTest.php b/tests/CancellationContextCauseTest.php new file mode 100644 index 00000000..da598e41 --- /dev/null +++ b/tests/CancellationContextCauseTest.php @@ -0,0 +1,279 @@ +isTimeout()); + self::assertNull($context->cause); + self::assertNull($context->timeoutKind); + self::assertNull($context->workDeadlineAt()); + self::assertSame($snapshot, $context->toArray()); + self::assertSame( + json_encode($snapshot, JSON_THROW_ON_ERROR), + json_encode($context->toArray(), JSON_THROW_ON_ERROR), + ); + self::assertSame(WorkflowCancelled::class, WorkflowCancelled::forContext('req-1', $context)::class); + } + + public function testAnExplicitCancellationCauseIsOrdinaryAndRoundTrips(): void + { + $snapshot = [...self::snapshot(), 'cause' => 'cancellation']; + $context = CancellationContext::fromArray($snapshot); + + self::assertFalse($context->isTimeout()); + self::assertSame('cancellation', $context->cause); + self::assertSame($snapshot, $context->toArray()); + self::assertSame(WorkflowCancelled::class, WorkflowCancelled::forContext('req-1', $context)::class); + } + + public function testANullCauseIsAnOrdinaryCancellation(): void + { + $context = CancellationContext::fromArray([...self::snapshot(), 'cause' => null]); + + self::assertFalse($context->isTimeout()); + self::assertSame(self::snapshot(), $context->toArray()); + } + + public function testATimeoutCauseRoundTripsAndDeliversTheTimeoutSubtype(): void + { + $snapshot = [...self::snapshot(), 'cause' => 'timeout']; + $context = CancellationContext::fromArray($snapshot); + + self::assertTrue($context->isTimeout()); + self::assertSame('timeout', $context->toArray()['cause']); + self::assertSame($snapshot, $context->toArray()); + $exception = WorkflowCancelled::forContext('req-1', $context); + self::assertInstanceOf(WorkflowTimeoutCleanupRequested::class, $exception); + self::assertInstanceOf(WorkflowCancelled::class, $exception); + self::assertSame($context, $exception->context); + self::assertSame('req-1', $exception->requestId); + } + + public function testTimeoutFieldsRoundTrip(): void + { + $snapshot = self::timeoutSnapshot(); + $context = CancellationContext::fromArray($snapshot); + + self::assertTrue($context->isTimeout()); + self::assertSame('execution', $context->timeoutKind); + self::assertSame('2026-11-13T00:01:00.000000+00:00', $context->workDeadlineAt()?->format('Y-m-d\TH:i:s.uP')); + self::assertSame($snapshot, $context->toArray()); + self::assertSame($snapshot, CancellationContext::fromArray($context->toArray())->toArray()); + + $offset = [...$snapshot, 'work_deadline_at' => '2026-11-12T19:01:00-05:00']; + self::assertSame($snapshot, CancellationContext::fromArray($offset)->toArray()); + } + + public function testADescendantIsCancelledNotTimedOut(): void + { + $parent = CancellationContext::fromArray(self::timeoutSnapshot()); + $descendant = $parent->toArray(); + // Built as Core's forDescendant does: the descendant was cancelled + // because its ancestor was, so it carries no cause. + unset($descendant['cause'], $descendant['timeout_kind'], $descendant['work_deadline_at']); + $descendant['request_id'] = 'req-child'; + $descendant['parent_request_id'] = 'req-1'; + $descendant['lineage'][] = [ + 'request_id' => 'req-child', + 'workflow_instance_id' => 'child-instance', + 'workflow_run_id' => 'child-run', + ]; + $context = CancellationContext::fromArray($descendant); + + self::assertFalse($context->isTimeout()); + self::assertSame($descendant, $context->toArray()); + self::assertSame(WorkflowCancelled::class, WorkflowCancelled::forContext('req-child', $context)::class); + } + + public function testAScopedRootFromATimeoutRunContextKeepsItsCause(): void + { + $scoped = ScopedCancellationContext::fromRunContext(CancellationContext::fromArray(self::timeoutSnapshot())); + + self::assertTrue($scoped->rootContext->isTimeout()); + self::assertSame(self::timeoutSnapshot(), $scoped->rootContext->toArray()); + self::assertSame($scoped->toArray(), ScopedCancellationContext::fromArray($scoped->toArray())->toArray()); + } + + /** @param array $snapshot */ + #[DataProvider('invalidCauseProvider')] + public function testAnInvalidCauseOrTimeoutFieldIsRefused(array $snapshot): void + { + $this->expectException(InvalidArgumentException::class); + + CancellationContext::fromArray($snapshot); + } + + /** @return array}> */ + public static function invalidCauseProvider(): array + { + $timeout = self::timeoutSnapshot(); + + return [ + 'unknown cause' => [[...self::snapshot(), 'cause' => 'deadline']], + 'empty cause' => [[...self::snapshot(), 'cause' => '']], + 'uppercase cause' => [[...self::snapshot(), 'cause' => 'TIMEOUT']], + 'non-string cause' => [[...self::snapshot(), 'cause' => true]], + 'list cause' => [[...self::snapshot(), 'cause' => ['timeout']]], + 'timeout kind without cause' => [[...self::snapshot(), 'timeout_kind' => 'execution']], + 'work deadline without cause' => [[...self::snapshot(), 'work_deadline_at' => '2026-11-13T00:01:00.000000Z']], + 'timeout kind with cancellation cause' => [[...self::snapshot(), 'cause' => 'cancellation', 'timeout_kind' => 'run']], + 'unknown timeout kind' => [[...$timeout, 'timeout_kind' => 'activity']], + 'non-string timeout kind' => [[...$timeout, 'timeout_kind' => 1]], + 'invalid work deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13 00:01:00']], + 'empty work deadline' => [[...$timeout, 'work_deadline_at' => '']], + 'work deadline at cleanup deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13T00:01:10.000000Z']], + 'work deadline after cleanup deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13T00:02:00.000000Z']], + ]; + } + + public function testReplayDeliversTheTimeoutSubtypeToExistingCancellationHandlers(): void + { + $caught = []; + $workflow = static function (WorkflowContext $context) use (&$caught): string { + try { + $context->sleep(10); + } catch (WorkflowCancelled $cancelled) { + $caught[] = $cancelled; + self::assertSame($cancelled->context, $context->cancellationContext()); + try { + $context->throwIfCancellationRequested(); + } catch (WorkflowCancelled $again) { + $caught[] = $again; + } + + return 'compensated'; + } + + return 'finished'; + }; + $snapshot = self::timeoutSnapshot(); + $result = (new Replayer(new AvroPayloadCodec()))->replay( + $workflow, + [self::request($snapshot), self::delivery($snapshot)], + [], + 'php-workers', + ['run_id' => 'run-1'], + ); + + self::assertSame('complete_workflow', $result->commands[0]['type']); + self::assertCount(2, $caught); + foreach ($caught as $exception) { + self::assertInstanceOf(WorkflowTimeoutCleanupRequested::class, $exception); + self::assertSame('req-1', $exception->requestId); + self::assertInstanceOf(CancellationContext::class, $exception->context); + self::assertTrue($exception->context->isTimeout()); + self::assertSame($snapshot, $exception->context->toArray()); + } + } + + public function testReplayDeliversThePlainCancellationForAnOrdinaryContext(): void + { + $caught = null; + $workflow = static function (WorkflowContext $context) use (&$caught): string { + try { + $context->sleep(10); + } catch (WorkflowCancelled $cancelled) { + $caught = $cancelled; + } + + return 'done'; + }; + $snapshot = self::snapshot(); + (new Replayer(new AvroPayloadCodec()))->replay( + $workflow, + [self::request($snapshot), self::delivery($snapshot)], + [], + 'php-workers', + ['run_id' => 'run-1'], + ); + + self::assertNotNull($caught); + self::assertSame(WorkflowCancelled::class, $caught::class); + self::assertSame($snapshot, $caught->context?->toArray()); + } + + /** @return array */ + private static function snapshot(): array + { + return [ + 'schema' => 'durable-workflow.cancellation-context/v1', + 'request_id' => 'req-1', + 'root_request_id' => 'req-1', + 'root_workflow_instance_id' => 'instance-1', + 'root_workflow_run_id' => 'run-1', + 'parent_request_id' => null, + 'reason' => 'work deadline passed', + 'requester' => [ + 'id' => 'timeout_cleanup', + 'type' => 'system', + ], + 'source' => 'system', + 'requested_at' => '2026-11-13T00:01:00.000000Z', + 'cleanup_deadline_at' => '2026-11-13T00:01:10.000000Z', + 'lineage' => [[ + 'request_id' => 'req-1', + 'workflow_instance_id' => 'instance-1', + 'workflow_run_id' => 'run-1', + ]], + ]; + } + + /** @return array */ + private static function timeoutSnapshot(): array + { + return [ + ...self::snapshot(), + 'cause' => 'timeout', + 'timeout_kind' => 'execution', + 'work_deadline_at' => '2026-11-13T00:01:00.000000Z', + ]; + } + + /** + * @param array $snapshot + * @return array + */ + private static function request(array $snapshot): array + { + return ['event_type' => 'CooperativeCancellationRequested', 'recorded_at' => '2026-11-13T00:01:00Z', 'payload' => [ + 'workflow_run_id' => 'run-1', 'workflow_instance_id' => 'instance-1', + 'workflow_command_id' => 'req-1', 'cleanup_deadline_at' => '2026-11-13T00:01:10.000000Z', + 'reason' => 'work deadline passed', 'cancellation' => $snapshot, + ]]; + } + + /** + * @param array $snapshot + * @return array + */ + private static function delivery(array $snapshot): array + { + return ['event_type' => 'CooperativeCancellationDelivered', 'payload' => [ + 'workflow_run_id' => 'run-1', 'workflow_command_id' => 'req-1', + 'sequence' => 1, 'call_kind' => 'timer', 'cancellation' => $snapshot, + ]]; + } +} diff --git a/tests/fixtures/replay-regressions/timeout-cleanup-cancellation-cause.json b/tests/fixtures/replay-regressions/timeout-cleanup-cancellation-cause.json new file mode 100644 index 00000000..99e7bdeb --- /dev/null +++ b/tests/fixtures/replay-regressions/timeout-cleanup-cancellation-cause.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "timeout-cleanup-cancellation-cause", + "protocol_version": "1.20", + "bindings": ["php"], + "workflow": {"type": "golden.timer", "input": [1]}, + "history": [ + {"event_type": "StartAccepted", "payload": {}}, + {"event_type": "WorkflowStarted", "payload": {}}, + { + "event_type": "CooperativeCancellationRequested", + "recorded_at": "2026-11-13T00:01:00Z", + "payload": { + "workflow_run_id": "regression-inline", "workflow_command_id": "request-1", + "cleanup_deadline_at": "2026-11-13T00:01:10.000000Z", "reason": "work deadline passed", + "cancellation": { + "schema": "durable-workflow.cancellation-context/v1", + "request_id": "request-1", "root_request_id": "request-1", + "root_workflow_instance_id": "regression-workflow", "root_workflow_run_id": "regression-inline", + "parent_request_id": null, "reason": "work deadline passed", + "requester": {"type": "system", "id": "timeout_cleanup"}, "source": "system", + "requested_at": "2026-11-13T00:01:00.000000Z", "cleanup_deadline_at": "2026-11-13T00:01:10.000000Z", + "lineage": [{"request_id": "request-1", "workflow_instance_id": "regression-workflow", "workflow_run_id": "regression-inline"}], + "cause": "timeout" + } + } + }, + { + "event_type": "CooperativeCancellationDelivered", + "payload": {"workflow_run_id": "regression-inline", "workflow_command_id": "request-1", "sequence": 1, "call_kind": "timer", "sequence_span": 1} + } + ], + "workflow_tasks": [{"workflow_id": "regression-workflow", "run_id": "regression-inline"}], + "expected": { + "workflow_tasks": [{"command_sequence": [{"type": "fail_workflow", "message": "Workflow work deadline passed: timeout cleanup requested.", "exception_type": "DurableWorkflow\\Exception\\WorkflowTimeoutCleanupRequested"}]}] + } +} From 6807eb6b9b849c0095b08b3f2fe89656c3b793a0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:13:40 +0000 Subject: [PATCH 2/3] Match the settled cause-only cancellation context contract --- CHANGELOG.md | 10 ++-- docs/cooperative-cancellation-design.md | 14 +++--- src/Worker/CancellationContext.php | 57 ++-------------------- src/Worker/ScopedCancellationContext.php | 3 -- tests/CancellationContextCauseTest.php | 60 +++++++++++------------- 5 files changed, 46 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 549da843..274504b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,15 @@ project follows [Semantic Versioning](https://semver.org/). workflow#1129). `CancellationContext` reads the optional top-level `cause` of `durable-workflow.cancellation-context/v1` and `v2`: `timeout` for a deadline-triggered timeout cleanup request, absent or `cancellation` for an - ordinary cancellation. Any other value is refused as invalid. A timeout - context may also carry `timeout_kind` (`execution` or `run`) and - `work_deadline_at`, refused without the timeout cause. `$cause`, - `isTimeout()`, `$timeoutKind` and `workDeadlineAt()` read them, and - `toArray()` writes them back as read; an ordinary context stays + ordinary cancellation. Any other value is refused as invalid. `$cause` and + `isTimeout()` read it, and `toArray()` writes it back as read; an ordinary context stays byte-identical. A timeout delivery raises the new experimental `WorkflowTimeoutCleanupRequested`, a subclass of `WorkflowCancelled`, so existing `catch (WorkflowCancelled)` handlers also run on timeout cleanup. `WorkflowCancelled::forContext()` picks the class for a delivered context. + This prepares the experimental parser and exception surface only. Runtime + timeout cleanup requires Core/native support and published-artifact qualification + (server#1201); workers do not advertise that capability yet. ### Changed diff --git a/docs/cooperative-cancellation-design.md b/docs/cooperative-cancellation-design.md index c96b8cb3..d9c3da85 100644 --- a/docs/cooperative-cancellation-design.md +++ b/docs/cooperative-cancellation-design.md @@ -163,19 +163,21 @@ A context can carry an optional top-level `cause` (workflow#863). `timeout` means deadline-triggered timeout cleanup requested the interruption: the run passed its work deadline and is now in cleanup. Absent or `cancellation` means an ordinary cooperative cancellation, and any other value is refused as -invalid. A timeout context may also name `timeout_kind` (`execution` or `run`) -and `work_deadline_at`; those fields are refused without the timeout cause. +invalid. `timeout_kind` and `work_deadline_at` belong to the +`WorkflowTimeoutCleanupStarted` history event, not the cancellation context. Its cleanup deadline is the context's existing `cleanup_deadline_at`. A descendant cancelled under the request is cancelled, not timed out, so it -carries no cause. `toArray()` writes `cause` and the timeout fields back as +carries no cause. `toArray()` writes `cause` back as read, and leaves an ordinary context unchanged. -`CancellationContext::$cause`, `isTimeout()`, `$timeoutKind` and -`workDeadlineAt()` read them. A timeout delivery raises +`CancellationContext::$cause` and `isTimeout()` read it. A timeout delivery raises `WorkflowTimeoutCleanupRequested`, which extends `WorkflowCancelled`, so existing `catch (WorkflowCancelled)` compensation runs on timeout too. Test `$cancelled->context?->isTimeout()` (the cross-SDK discriminator is `cause`) -when cleanup must differ. The run still ends timed out. +when cleanup must differ. This is preparatory experimental support. Runtime +admission and delivery remain pending in [server#1201](https://github.com/durable-workflow/server/issues/1201). +The SDK does not advertise timeout cleanup support. The portable contract +requires the run to end timed out after cleanup. ## Child policies diff --git a/src/Worker/CancellationContext.php b/src/Worker/CancellationContext.php index c8777fe9..86433193 100644 --- a/src/Worker/CancellationContext.php +++ b/src/Worker/CancellationContext.php @@ -44,9 +44,6 @@ private function __construct( public readonly ?ScopedCancellationContext $scopeOrigin = null, /** Wire `cause` as recorded: null (absent), `cancellation` or `timeout`. */ public readonly ?string $cause = null, - /** `execution` or `run` when a timeout context names its timeout kind. */ - public readonly ?string $timeoutKind = null, - private readonly ?DateTimeImmutable $workDeadline = null, ) { } @@ -126,7 +123,10 @@ public static function fromArray(array $snapshot): self if ($deadline <= $requestedAt) { throw new InvalidArgumentException('Cancellation deadline must follow the original request.'); } - [$cause, $timeoutKind, $workDeadline] = self::cause($snapshot, $deadline); + $cause = $snapshot['cause'] ?? null; + if ($cause !== null && !in_array($cause, [self::CAUSE_CANCELLATION, self::CAUSE_TIMEOUT], true)) { + throw new InvalidArgumentException('Cancellation cause must be cancellation or timeout.'); + } if ($scopeOrigin !== null) { self::assertScopedOrigin($snapshot, $scopeOrigin, $normalized, $requester, $requestedAt, $deadline); } @@ -134,7 +134,7 @@ public static function fromArray(array $snapshot): self return new self( $requestId, $rootRequestId, $rootInstanceId, $rootRunId, $parentRequestId, $reason, $requester, self::text($snapshot, 'source'), $requestedAt, $deadline, $normalized, $scopeOrigin, - $cause, $timeoutKind, $workDeadline, + $cause, ); } @@ -148,12 +148,6 @@ public function deadline(): DateTimeImmutable return $this->cleanupDeadline; } - /** The work deadline a timeout context names, if it names one. */ - public function workDeadlineAt(): ?DateTimeImmutable - { - return $this->workDeadline; - } - /** Remaining seconds at the consumed replay boundary, never the host clock. */ public function remaining(): float { @@ -197,10 +191,6 @@ public function toArray(): array 'cleanup_deadline_at' => $this->cleanupDeadline->format('Y-m-d\TH:i:s.u\Z'), 'lineage' => $this->lineage, ...($this->cause === null ? [] : ['cause' => $this->cause]), - ...($this->timeoutKind === null ? [] : ['timeout_kind' => $this->timeoutKind]), - ...($this->workDeadline === null ? [] : [ - 'work_deadline_at' => $this->workDeadline->format('Y-m-d\TH:i:s.u\Z'), - ]), ...($this->scopeOrigin === null ? [] : [ 'scope_origin' => $this->scopeOrigin->toArray(), 'scope_authority_deadline_at' => $this->cleanupDeadline->format('Y-m-d\TH:i:s.u\Z'), @@ -258,43 +248,6 @@ private static function assertScopedOrigin( } } - /** - * Read the optional top-level `cause` and, for a timeout, its timeout - * fields. Unknown causes, and timeout fields without the timeout cause, - * are refused. - * - * @param array $snapshot - * @return array{?string, ?string, ?DateTimeImmutable} - */ - private static function cause(array $snapshot, DateTimeImmutable $deadline): array - { - $cause = $snapshot['cause'] ?? null; - if ($cause !== null && !in_array($cause, [self::CAUSE_CANCELLATION, self::CAUSE_TIMEOUT], true)) { - throw new InvalidArgumentException('Cancellation cause must be cancellation or timeout.'); - } - $timeoutKind = $snapshot['timeout_kind'] ?? null; - $workDeadlineAt = $snapshot['work_deadline_at'] ?? null; - if ($cause !== self::CAUSE_TIMEOUT) { - if ($timeoutKind !== null || $workDeadlineAt !== null) { - throw new InvalidArgumentException('Cancellation timeout fields require the timeout cause.'); - } - - return [$cause, null, null]; - } - if ($timeoutKind !== null && !in_array($timeoutKind, ['execution', 'run'], true)) { - throw new InvalidArgumentException('Cancellation timeout_kind must be execution or run.'); - } - $workDeadline = null; - if ($workDeadlineAt !== null) { - $workDeadline = self::timestamp(self::text($snapshot, 'work_deadline_at')); - if ($workDeadline >= $deadline) { - throw new InvalidArgumentException('Cancellation work deadline must precede its cleanup deadline.'); - } - } - - return [$cause, $timeoutKind, $workDeadline]; - } - /** @param array $value */ private static function text(array $value, string $key): string { diff --git a/src/Worker/ScopedCancellationContext.php b/src/Worker/ScopedCancellationContext.php index 8f6b7430..8d51a9fd 100644 --- a/src/Worker/ScopedCancellationContext.php +++ b/src/Worker/ScopedCancellationContext.php @@ -111,9 +111,6 @@ public static function fromArray(array $snapshot): self } $deadlineSnapshot = $root->toArray(); $deadlineSnapshot['cleanup_deadline_at'] = $entry['cleanup_deadline_at']; - // Only the budget is checked here; a narrowed scope budget is not - // compared with a timeout root's work deadline. - unset($deadlineSnapshot['timeout_kind'], $deadlineSnapshot['work_deadline_at']); $nextDeadline = CancellationContext::fromArray($deadlineSnapshot)->deadline(); if (($index === 0 && $nextDeadline != $root->deadline()) || $nextDeadline > $deadline) { throw new InvalidArgumentException('Scoped cancellation cannot change its root or extend a descendant budget.'); diff --git a/tests/CancellationContextCauseTest.php b/tests/CancellationContextCauseTest.php index da598e41..5518196f 100644 --- a/tests/CancellationContextCauseTest.php +++ b/tests/CancellationContextCauseTest.php @@ -28,8 +28,6 @@ public function testAnOrdinaryContextIsUnchangedAndStaysACancellation(): void self::assertFalse($context->isTimeout()); self::assertNull($context->cause); - self::assertNull($context->timeoutKind); - self::assertNull($context->workDeadlineAt()); self::assertSame($snapshot, $context->toArray()); self::assertSame( json_encode($snapshot, JSON_THROW_ON_ERROR), @@ -72,28 +70,13 @@ public function testATimeoutCauseRoundTripsAndDeliversTheTimeoutSubtype(): void self::assertSame('req-1', $exception->requestId); } - public function testTimeoutFieldsRoundTrip(): void - { - $snapshot = self::timeoutSnapshot(); - $context = CancellationContext::fromArray($snapshot); - - self::assertTrue($context->isTimeout()); - self::assertSame('execution', $context->timeoutKind); - self::assertSame('2026-11-13T00:01:00.000000+00:00', $context->workDeadlineAt()?->format('Y-m-d\TH:i:s.uP')); - self::assertSame($snapshot, $context->toArray()); - self::assertSame($snapshot, CancellationContext::fromArray($context->toArray())->toArray()); - - $offset = [...$snapshot, 'work_deadline_at' => '2026-11-12T19:01:00-05:00']; - self::assertSame($snapshot, CancellationContext::fromArray($offset)->toArray()); - } - public function testADescendantIsCancelledNotTimedOut(): void { $parent = CancellationContext::fromArray(self::timeoutSnapshot()); $descendant = $parent->toArray(); // Built as Core's forDescendant does: the descendant was cancelled // because its ancestor was, so it carries no cause. - unset($descendant['cause'], $descendant['timeout_kind'], $descendant['work_deadline_at']); + unset($descendant['cause']); $descendant['request_id'] = 'req-child'; $descendant['parent_request_id'] = 'req-1'; $descendant['lineage'][] = [ @@ -117,9 +100,34 @@ public function testAScopedRootFromATimeoutRunContextKeepsItsCause(): void self::assertSame($scoped->toArray(), ScopedCancellationContext::fromArray($scoped->toArray())->toArray()); } + public function testAScopedDescendantDoesNotInheritItsTimeoutRootsCause(): void + { + $origin = ScopedCancellationContext::fromRunContext(CancellationContext::fromArray(self::timeoutSnapshot())); + // Core's fromScopeContext preserves the timed-out ancestor in scope_origin, + // while the child's own top-level context has no timeout cause. + $snapshot = [ + ...self::snapshot(), + 'schema' => 'durable-workflow.cancellation-context/v2', + 'request_id' => 'req-child', + 'parent_request_id' => 'req-1', + 'lineage' => [...self::snapshot()['lineage'], [ + 'request_id' => 'req-child', 'workflow_instance_id' => 'child-instance', 'workflow_run_id' => 'child-run', + ]], + 'scope_origin' => $origin->toArray(), + 'scope_authority_deadline_at' => '2026-11-13T00:01:10.000000Z', + ]; + $context = CancellationContext::fromArray($snapshot); + + self::assertFalse($context->isTimeout()); + self::assertTrue($context->scopeOrigin?->rootContext->isTimeout()); + self::assertSame($snapshot, $context->toArray()); + self::assertSame(WorkflowCancelled::class, WorkflowCancelled::forContext('req-child', $context)::class); + self::assertFalse(CancellationContext::fromArray($context->toArray())->isTimeout()); + } + /** @param array $snapshot */ #[DataProvider('invalidCauseProvider')] - public function testAnInvalidCauseOrTimeoutFieldIsRefused(array $snapshot): void + public function testAnInvalidCauseIsRefused(array $snapshot): void { $this->expectException(InvalidArgumentException::class); @@ -129,23 +137,13 @@ public function testAnInvalidCauseOrTimeoutFieldIsRefused(array $snapshot): void /** @return array}> */ public static function invalidCauseProvider(): array { - $timeout = self::timeoutSnapshot(); - return [ 'unknown cause' => [[...self::snapshot(), 'cause' => 'deadline']], 'empty cause' => [[...self::snapshot(), 'cause' => '']], 'uppercase cause' => [[...self::snapshot(), 'cause' => 'TIMEOUT']], 'non-string cause' => [[...self::snapshot(), 'cause' => true]], 'list cause' => [[...self::snapshot(), 'cause' => ['timeout']]], - 'timeout kind without cause' => [[...self::snapshot(), 'timeout_kind' => 'execution']], - 'work deadline without cause' => [[...self::snapshot(), 'work_deadline_at' => '2026-11-13T00:01:00.000000Z']], - 'timeout kind with cancellation cause' => [[...self::snapshot(), 'cause' => 'cancellation', 'timeout_kind' => 'run']], - 'unknown timeout kind' => [[...$timeout, 'timeout_kind' => 'activity']], - 'non-string timeout kind' => [[...$timeout, 'timeout_kind' => 1]], - 'invalid work deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13 00:01:00']], - 'empty work deadline' => [[...$timeout, 'work_deadline_at' => '']], - 'work deadline at cleanup deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13T00:01:10.000000Z']], - 'work deadline after cleanup deadline' => [[...$timeout, 'work_deadline_at' => '2026-11-13T00:02:00.000000Z']], + 'integer cause' => [[...self::snapshot(), 'cause' => 1]], ]; } @@ -247,8 +245,6 @@ private static function timeoutSnapshot(): array return [ ...self::snapshot(), 'cause' => 'timeout', - 'timeout_kind' => 'execution', - 'work_deadline_at' => '2026-11-13T00:01:00.000000Z', ]; } From f37240338b7de25bd6e55b4b2230a1f4e70c1705 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:25:12 +0000 Subject: [PATCH 3/3] Preserve caller messages when selecting a cancellation subtype --- src/Exception/WorkflowCancelled.php | 12 ++++++++---- tests/CancellationContextCauseTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Exception/WorkflowCancelled.php b/src/Exception/WorkflowCancelled.php index fa1efec3..c6f2b2a1 100644 --- a/src/Exception/WorkflowCancelled.php +++ b/src/Exception/WorkflowCancelled.php @@ -33,10 +33,14 @@ public function __construct( public static function forContext( ?string $requestId, CancellationContext|ScopedCancellationContext|null $context, - string $message = 'Workflow cancellation was requested.', + ?string $message = null, ): self { - return $context instanceof CancellationContext && $context->isTimeout() - ? new WorkflowTimeoutCleanupRequested(requestId: $requestId, context: $context) - : new self($message, requestId: $requestId, context: $context); + if ($context instanceof CancellationContext && $context->isTimeout()) { + return $message === null + ? new WorkflowTimeoutCleanupRequested(requestId: $requestId, context: $context) + : new WorkflowTimeoutCleanupRequested($message, requestId: $requestId, context: $context); + } + + return new self($message ?? 'Workflow cancellation was requested.', requestId: $requestId, context: $context); } } diff --git a/tests/CancellationContextCauseTest.php b/tests/CancellationContextCauseTest.php index 5518196f..10c752de 100644 --- a/tests/CancellationContextCauseTest.php +++ b/tests/CancellationContextCauseTest.php @@ -70,6 +70,31 @@ public function testATimeoutCauseRoundTripsAndDeliversTheTimeoutSubtype(): void self::assertSame('req-1', $exception->requestId); } + #[DataProvider('contextMessageProvider')] + public function testTheContextFactoryPreservesExplicitMessages(?string $cause, string $expectedDefault): void + { + $context = CancellationContext::fromArray([...self::snapshot(), 'cause' => $cause]); + $default = WorkflowCancelled::forContext('req-1', $context); + self::assertSame($expectedDefault, $default->getMessage()); + + foreach (['Caller-supplied message.', ''] as $message) { + $exception = WorkflowCancelled::forContext('req-1', $context, $message); + self::assertSame($message, $exception->getMessage()); + self::assertSame($default::class, $exception::class); + self::assertSame($context, $exception->context); + self::assertSame('req-1', $exception->requestId); + } + } + + /** @return array */ + public static function contextMessageProvider(): array + { + return [ + 'ordinary cancellation' => [null, 'Workflow cancellation was requested.'], + 'timeout cleanup' => ['timeout', 'Workflow work deadline passed: timeout cleanup requested.'], + ]; + } + public function testADescendantIsCancelledNotTimedOut(): void { $parent = CancellationContext::fromArray(self::timeoutSnapshot());