Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/cooperative-cancellation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion src/Exception/WorkflowCancelled.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '',
Expand All @@ -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);
}
}
30 changes: 30 additions & 0 deletions src/Exception/WorkflowTimeoutCleanupRequested.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Exception;

use DurableWorkflow\Worker\CancellationContext;
use DurableWorkflow\Worker\ScopedCancellationContext;

/**
* Delivered to workflow code when its work deadline passes and the run opted
* in to deadline-triggered timeout cleanup (workflow#863). It is a
* cooperative cancellation request, so existing `catch (WorkflowCancelled)`
* handlers (saga compensation) run; the run still ends timed out. Its
* context's `cause` is `timeout`.
*
* @experimental
*/
final class WorkflowTimeoutCleanupRequested extends WorkflowCancelled
{
public function __construct(
string $message = 'Workflow work deadline passed: timeout cleanup requested.',
int $code = 0,
?\Throwable $previous = null,
?string $requestId = null,
CancellationContext|ScopedCancellationContext|null $context = null,
) {
parent::__construct($message, $code, $previous, $requestId, $context);
}
}
23 changes: 23 additions & 0 deletions src/Worker/CancellationContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
/** Immutable root request and cleanup budget read from canonical history. */
final class CancellationContext
{
/**
* `cause` of a run's own interruption when deadline-triggered timeout
* cleanup requested it (workflow#863). Absent or `cancellation` is an
* ordinary cooperative cancellation.
*/
public const CAUSE_TIMEOUT = 'timeout';

public const CAUSE_CANCELLATION = 'cancellation';

/** @var (Closure(): DateTimeImmutable)|null */
private ?Closure $replayClock = null;

Expand All @@ -33,9 +42,17 @@ private function __construct(
private readonly DateTimeImmutable $cleanupDeadline,
public readonly array $lineage,
public readonly ?ScopedCancellationContext $scopeOrigin = null,
/** Wire `cause` as recorded: null (absent), `cancellation` or `timeout`. */
public readonly ?string $cause = null,
) {
}

/** Whether deadline-triggered timeout cleanup requested this interruption. */
public function isTimeout(): bool
{
return $this->cause === self::CAUSE_TIMEOUT;
}

/** @param array<string, mixed> $snapshot */
public static function fromArray(array $snapshot): self
{
Expand Down Expand Up @@ -106,13 +123,18 @@ 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);
}

return new self(
$requestId, $rootRequestId, $rootInstanceId, $rootRunId, $parentRequestId,
$reason, $requester, self::text($snapshot, 'source'), $requestedAt, $deadline, $normalized, $scopeOrigin,
$cause,
);
}

Expand Down Expand Up @@ -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'),
Expand Down
7 changes: 2 additions & 5 deletions src/Worker/WorkflowContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading