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
36 changes: 34 additions & 2 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ The embedded package and the service-mode SDK have separate support contracts.

| Package line | PHP | Laravel |
| --- | --- | --- |
| Current embedded Workflow 2.24 | PHP 8.2+ in 8.x; PHP 8.3+ for Laravel 13 | `^12.69.0` or `^13.30.0` |
| Current embedded Workflow 2.26 | PHP 8.2+ in 8.x; PHP 8.3+ for Laravel 13 | `^12.69.0` or `^13.30.0` |
| Embedded Workflow 2.20.5 | PHP `^8.1`, subject to Laravel's own minimum | `^10.50.4`, `^11.56.1`, `^12.69.0` or `^13.30.0` |
| Published PHP SDK 3.14 | Bare SDK PHP `^8.1`; the Laravel bridge needs PHP `^8.2`, and PHP 8.3+ for Laravel 13 | `^12.69.0` or `^13.30.0` |
| PHP SDK 3.9.5 | Bare SDK PHP `^8.1`; the Laravel bridge also follows Laravel's PHP minimum | `^10.50.4`, `^11.56.1`, `^12.69.0` or `^13.30.0` |

The [published embedded upgrade contract](https://github.com/durable-workflow/workflow/blob/2.24.0/resources/laravel-embedded-upgrade-contract.json)
The [published embedded upgrade contract](https://github.com/durable-workflow/workflow/blob/2.26.0/resources/laravel-embedded-upgrade-contract.json)
and each release's `composer.json` are the package authorities. The current
embedded release was verified on Laravel 12 with PHP 8.2–8.4 and Laravel 13 with
PHP 8.3–8.4. Its PHP 8.1 and Laravel 10/11 support has been removed. This does
Expand Down Expand Up @@ -161,6 +161,38 @@ See the release notes for
[2.23.2](https://github.com/durable-workflow/workflow/releases/tag/2.23.2) and
[2.24.0](https://github.com/durable-workflow/workflow/releases/tag/2.24.0).

Workflow 2.25 and 2.26 keep the 2.21 support floors and the embedded worker
protocol `1.22`. 2.25 adds no database migration. 2.26 adds one, so run
`php artisan migrate` when you upgrade. It creates the
`workflow_child_supervisions` table. Changes to check before upgrading:

- From 2.26, a parent that continues as new while children it started are
still open hands them to the continued run. The chain's final run applies
their parent-close policy, and a child that fails is still retried under its
recorded retry policy. See
[Continue-as-new and open children](./features/child-workflows.md#continue-as-new-and-open-children).
- From 2.26, the migrations from earlier releases that fill in existing rows
(`2026_07_13`, `2026_09_24`, `2026_09_29` and `2026_10_11`) no longer lock the
table for the whole backfill. They add the column first, fill rows in small
batches and then build their indexes without blocking writes. If one of them
is interrupted, run `php artisan migrate` again. Installs that already ran
them are not affected.
- From 2.26, redrive reuses a child workflow that completed before the failure.
Releases before 2.26 replay such a successor with a different workflow time
after the reused child, so keep workers on 2.26 or later once one exists. See
[Redrive a Failed Run](./failures-and-recovery.md#redrive).
- From 2.26, `historyBudgetAtStep()` and `shouldContinueAsNewAtStep()` read
the history budget the same way on every replay. Use them in loops. See
[History Budget](./features/continue-as-new.md#history-budget).
- From 2.25, going over a fixed memo or search attribute cap raises
`StructuralLimitExceededException` instead of `InvalidArgumentException`,
and the `memo_size_bytes` default is 65,536. See
[Structural Limits](./constraints/structural-limits.md).

See the release notes for
[2.25.0](https://github.com/durable-workflow/workflow/releases/tag/2.25.0) and
[2.26.0](https://github.com/durable-workflow/workflow/releases/tag/2.26.0).

## Stability Levels

Every public surface in Durable Workflow carries exactly one of these
Expand Down
4 changes: 4 additions & 0 deletions docs/defining-workflows/workflow-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ abstract class Workflow
public function historyLength(): int;
public function historySize(): int;
public function shouldContinueAsNew(): bool;
public function historyBudgetAtStep(): array;
public function shouldContinueAsNewAtStep(): bool;
}
```

Expand All @@ -67,6 +69,8 @@ abstract class Workflow
| `historyLength()` | The workflow needs a count-based history budget signal. | Current history event count. |
| `historySize()` | The workflow needs a byte-based history budget signal. | Approximate persisted history size in bytes. |
| `shouldContinueAsNew()` | The workflow should rotate before history becomes expensive. | `true` when configured history budgets recommend rotation. |
| `historyBudgetAtStep()` | The workflow reads the budget more than once in a run, for example in a loop (Workflow 2.26+). | Budget counters as of the current step, the same on every replay. |
| `shouldContinueAsNewAtStep()` | A loop decides whether to rotate (Workflow 2.26+). | `true` when the history before the current step crosses a configured budget. |

## Durable commands

Expand Down
21 changes: 15 additions & 6 deletions docs/failures-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ The successor replays workflow code from the start:
- A memo or search attribute that workflow code upserted before the failed step is reused in step order, and the successor keeps the same values.
- A signal wait (`await('name')`) that received and applied its signal is reused with the signal's value. The signal is not delivered again.
- An update that was accepted, applied and completed before the failed step is recorded again at the same position. Replay runs the update handler from its recorded arguments, so the workflow's state matches. The update is not delivered again. A rejected update, and one the failure closed before it ran, are left out.
- In Workflow 2.26 and later, a child workflow call that completed before the failed step returns its recorded result. The child is not started again. The successor records the call's `ChildWorkflowScheduled`, `ChildRunStarted` and `ChildRunCompleted` events and keeps the original child instance, child run, link id and call id, but gets no child link of its own.
- `outbox()->sendReference()` and `inbox()->receive()` in reused workflow code return the message the failed run sent or consumed. They don't send or consume it again. A replayed receive whose original call found nothing reads the stream again.
- In Workflow 2.20.2 and later, inbound stream messages still pending on the failed run move to the successor. Workflow 2.20.1 and earlier left them on the failed run, so the successor never received them ([workflow#1013](https://github.com/durable-workflow/workflow/issues/1013)).
- If an activity failure ended the run, the failed activity call is scheduled as a new activity execution, using the retry policy in the workflow code. Its attempts start again.
Expand All @@ -126,7 +127,7 @@ The successor replays workflow code from the start:
- The successor keeps the failed run's execution deadline. Its run timeout starts again at the redrive.
- Each reused step carries `reused_from_run_id` and `reused_recorded_at`. On a reused update or signal event, the two always come as a pair.
- A successor that reused steps can be redriven again. A reused signal or update payload keeps the run that first recorded it in `workflow_run_id`, `reused_from_run_id` names the run it was redriven from, and every reused step keeps the time it first ran, so a chain of redrives reuses each step once.
- In embedded mode, `Workflow::now()` in the successor starts at the failed run's start time. Replay uses `reused_recorded_at`, so `Workflow::now()` after a reused step equals the time in the failed run. A step reused by a second redrive keeps the time it first ran.
- In embedded mode, `Workflow::now()` in the successor starts at the failed run's start time. Replay uses `reused_recorded_at`, so `Workflow::now()` after a reused step equals the time in the failed run. After a reused child workflow call, that is the time the child first finished. A step reused by a second redrive keeps the time it first ran.

### Request a redrive {#request-a-redrive}

Expand Down Expand Up @@ -182,11 +183,12 @@ Redrive accepts a failed run when all of these are true:

- The run is the instance's current run.
- The run failed because an activity failure reached workflow code and was not caught, or because workflow code threw or a structural limit stopped it. A run that failed on a workflow timeout is refused.
- Every step before the failed step is a completed activity, a side effect, a version marker, a `timer()` that fired, a memo or search-attribute upsert, a signal wait that received and applied its signal, or an update that was accepted, applied and completed with the same arguments. A rejected update, and one the failure closed before it ran, are allowed and left out. Activity, side effect, signal and update payloads must be stored inline. A payload held in external payload storage is refused.
- Every step before the failed step is a completed activity, a side effect, a version marker, a `timer()` that fired, a memo or search-attribute upsert, a signal wait that received and applied its signal, an update that was accepted, applied and completed with the same arguments, or, in Workflow 2.26 and later, a child workflow call that completed. A rejected update, and one the failure closed before it ran, are allowed and left out. Activity, side effect, signal, update and child result payloads must be stored inline. A payload held in external payload storage is refused.
- The workflow definition matches the fingerprint recorded when the run started, or you passed `allow_definition_change` and the corrected code replays the completed steps. A workflow-code failure on the same definition is refused.
- The execution deadline has not passed.
- The run has no parent workflow, no child workflows and no local activities, and its input is stored inline.
- If a service worker started the run, the steps before the failure are only activities, side effects, version markers and fired timers. Memo and search-attribute upserts, signal waits and applied updates are refused for service runs.
- The run has no parent workflow and no local activities, and its input is stored inline. Before Workflow 2.26, it also has no child workflows. A run that is itself a child is never redriven, because its parent has already recorded the child's outcome.
- Each child workflow call before the failed step completed on its own, outside a parallel group or cancellation scope, without a retry or continue-as-new.
- If a service worker started the run, the steps before the failure are only activities, side effects, version markers and fired timers. Memo and search-attribute upserts, signal waits, applied updates and completed child workflow calls are refused for service runs.

A refusal names the first rule that failed:

Expand All @@ -196,7 +198,12 @@ A refusal names the first rule that failed:
| `source_run_not_current` | A newer run of the instance exists. |
| `unrecorded_failure_boundary` | The run's failure has no recorded step, such as a workflow-code failure recorded by a release before 2.19.0. |
| `workflow_code_failure_requires_corrected_definition` | Workflow code threw, and the definition hasn't changed, so the same code would throw again. |
| `unsupported_history` | The history before the failure contains a step that redrive cannot reuse, such as a child workflow or a cancelled timer. |
| `unsupported_history` | The history before the failure contains a step that redrive cannot reuse, such as a cancelled timer. Before Workflow 2.26, any child workflow. |
| `unsupported_open_child` | A child workflow call before the failure never closed. Workflow 2.26 and later. |
| `unsupported_child_outcome` | A child workflow before the failure failed, was cancelled or was terminated. Workflow 2.26 and later. |
| `unsupported_child_workflow` | A child workflow call is in a parallel group or cancellation scope, was retried or continued as new, or its events don't name one child call. Workflow 2.26 and later. |
| `external_child_result_not_supported` | A completed child workflow's result is stored in external payload storage. Workflow 2.26 and later. |
| `unavailable_child_result` | A completed child workflow's result cannot be decoded. Workflow 2.26 and later. |
| `unsupported_local_activity` | The history contains a local activity. |
| `unsupported_timer` | The history contains a condition or signal timeout, a cancellation cleanup timer, or a timer inside a cancellation scope or parallel group. |
| `incomplete_timer_step` | A timer step does not have both its scheduled and fired events. |
Expand All @@ -223,7 +230,7 @@ A refusal names the first rule that failed:
| `definition_incompatible_with_prefix` | The corrected code can't replay the completed steps. `definition_mismatch` names the first step that differs. |
| `definition_check_over_budget` | The completed history is larger than the history budget the replay check allows. |
| `definition_change_requires_replay_check` | A service worker started the run, so redrive can't check a changed definition. |
| `service_redrive_history_not_qualified` | A service worker started the run, and the steps before the failure include a memo upsert, search-attribute upsert, signal wait or applied update. Redrive refuses these steps for service runs until it can check each SDK runtime's replay capability for them. |
| `service_redrive_history_not_qualified` | A service worker started the run, and the steps before the failure include a memo upsert, search-attribute upsert, signal wait, applied update or completed child workflow call. Redrive refuses these steps for service runs until it can check each SDK runtime's replay capability for them. |
| `request_id_already_used` | The `request_id` was already used for another redrive of the instance. |
| `already_redriven` | Reported by run detail and `describe()` for a run that was already redriven. A redrive request for it returns the existing successor instead. |
| `invalid_request_id` | The `request_id` is longer than 191 characters. |
Expand All @@ -245,6 +252,8 @@ In Workflow 2.20.0 and later, run detail and `describe()` report the redrive lin

### Earlier releases and the native Server {#redrive-earlier-releases}

Workflow releases before 2.26.0 refuse any child workflow in the history with `unsupported_history`. They ignore `reused_recorded_at` on child workflow events, so a 2.26.0 successor that reused a child call can replay there with a different workflow time, and different deadlines, after the call. They also refuse to redrive it again. Once such a successor exists, keep workers on 2.26.0 or later, or confirm that no such history can replay, before you roll back.

Workflow 2.20.0 refuses nothing that 2.20.1 accepts. In 2.20.0, `outbox()->sendReference()` in reused workflow code sends again, and a replayed `inbox()->receive()` can return different messages or none, or consume a newer message ([workflow#1010](https://github.com/durable-workflow/workflow/issues/1010)).

Workflow 2.19.0 refuses an applied update (`unsupported_history`). It also refuses to redrive a successor that reused an update (`unsupported_history`) or a signal wait (`mismatched_signal_receipt`). Run detail and `describe()` show redrive links as diagnostic fallbacks.
Expand Down
16 changes: 14 additions & 2 deletions docs/features/child-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ $results = all([
- When the parent run closes for any reason, the engine queries open child links with a non-abandon policy and sends the appropriate command (request cancellation, cancel, or terminate) to each open child.
- If the child has already closed by the time the policy is enforced, no action is taken — the command is silently skipped.
- Policy enforcement is best-effort: if a child command is rejected (e.g. the child is already terminal), the parent's closure is not affected. When enforcement succeeds, a `ParentClosePolicyApplied` history event is recorded on the parent run. When enforcement fails, a `ParentClosePolicyFailed` history event is recorded instead, so operators can distinguish successful enforcement from silent failures.
- Continue-as-new does **not** trigger parent-close policy, because the workflow instance remains active under a new run.
- Continue-as-new does **not** trigger parent-close policy, because the workflow instance remains active under a new run. From Workflow 2.26, the chain's final run applies it instead. See [Continue-as-new and open children](#continue-as-new-and-open-children).

### Parent disposition matrix

Expand All @@ -216,9 +216,21 @@ Parent-close policy fires on every terminal parent disposition, and stays inert
| Timed out | Yes | Fires after `WorkflowTimedOut` is recorded for run-timeout and execution-timeout closures. |
| Cancelled | Yes | Fires after the accepted `CancelRequested` command closes the parent run. |
| Terminated | Yes | Fires after the accepted `TerminateRequested` command closes the parent run. |
| Continue-as-new | No | The workflow instance stays active under the new run, so existing child links are re-pointed at the continued run with their original policy preserved. |
| Continue-as-new | No | The workflow instance stays active under the new run. From Workflow 2.26, the continued run takes over supervising the open children, and the chain's final run applies their policy. |
| Reset | Not currently applicable | The v2 runtime does not expose a standalone reset command or reset terminal disposition. A future reset or repair operation that closes or replaces a parent run must define whether child ownership transfers; if the old run becomes terminal, it must apply parent-close policy before it stops owning open children. |

### Continue-as-new and open children {#continue-as-new-and-open-children}

In Workflow 2.26 and later, a parent that continues as new while children it started are still open hands them to the continued run, in the same transaction. Embedded and service-mode runs behave the same:

- Each open child gets a row in the `workflow_child_supervisions` table, which the 2.26 migration creates. A child that has already closed is not handed on.
- The child link, child call, sequence, deadlines and parent-close policy stay as the starting run recorded them. When the continued run continues as new again, it hands the children on again.
- When the chain's final run completes, fails, times out, is cancelled or is terminated, it applies each child's policy and records `ParentClosePolicyApplied` or `ParentClosePolicyFailed` in its own history. `abandon` stays `abandon`. The closed earlier run gets no new history.
- The continued run has no handle on these children, and their results are not delivered to it. Pass anything the next run needs in the arguments to `continueAsNew()`.
- A handed-on child that fails is retried under the retry policy the starting run recorded, with the same child call identity and attempt count, while a run of the chain still supervises it. The retry run's `WorkflowStarted` names the starting run as its parent, and the chain's final run applies the parent-close policy to the retry run. The failure is final once the attempts are spent, the failure is non-retryable, or the chain has closed.

Before 2.26, these children kept running after the chain closed and their policy was never applied, and a child that failed after its parent continued as new was not retried. See the [2.26.0 release notes](https://github.com/durable-workflow/workflow/releases/tag/2.26.0).

### When to use each policy

**Abandon** (default) is correct when children represent independent work that should complete regardless of the parent's fate — for example, a notification workflow or a cleanup task that must finish.
Expand Down
Loading
Loading