diff --git a/CHANGELOG.md b/CHANGELOG.md index 91073c07..274504b8 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. `$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 + +- `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..d9c3da85 100644 --- a/docs/cooperative-cancellation-design.md +++ b/docs/cooperative-cancellation-design.md @@ -157,6 +157,28 @@ 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. `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` back as +read, and leaves an ordinary context unchanged. + +`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. 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 Child authoring accepts `CancellationPolicy` and `ParentClosePolicy` values from diff --git a/src/Exception/WorkflowCancelled.php b/src/Exception/WorkflowCancelled.php index 18e94bac..c6f2b2a1 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,22 @@ 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 = null, + ): self { + 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/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 +123,10 @@ public static function fromArray(array $snapshot): self if ($deadline <= $requestedAt) { throw new InvalidArgumentException('Cancellation deadline must follow the original request.'); } + $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); } @@ -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, ); } @@ -168,6 +190,7 @@ 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->scopeOrigin === null ? [] : [ 'scope_origin' => $this->scopeOrigin->toArray(), 'scope_authority_deadline_at' => $this->cleanupDeadline->format('Y-m-d\TH:i:s.u\Z'), 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..10c752de --- /dev/null +++ b/tests/CancellationContextCauseTest.php @@ -0,0 +1,300 @@ +isTimeout()); + self::assertNull($context->cause); + 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); + } + + #[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()); + $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['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()); + } + + 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 testAnInvalidCauseIsRefused(array $snapshot): void + { + $this->expectException(InvalidArgumentException::class); + + CancellationContext::fromArray($snapshot); + } + + /** @return array}> */ + public static function invalidCauseProvider(): array + { + 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']]], + 'integer cause' => [[...self::snapshot(), 'cause' => 1]], + ]; + } + + 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', + ]; + } + + /** + * @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"}]}] + } +}