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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ Follow the [Durable Workflow organization-wide agent guide](https://github.com/d
Repository-specific build and test commands live in this repository's README
and contributing guide. The organization guide is authoritative for shared
product, issue, security, conformance, and release rules.

Keep `docs/features/` and the Embedded sidebar focused on the Laravel workflow
package. Put language-specific service-mode examples on the corresponding SDK
site; link to them instead of mixing SDK code into embedded guides. Shared
Server capability and routing references belong under Service Mode. A worker
that explicitly refuses a capability does not implement that feature.
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ implied to have parity.
| Namespaces | Service-mode runtimes are namespace-scoped. PHP/Python clients and CLI manage namespaces where the selected runtime exposes that operation; Rust workers/clients target a namespace but do not claim namespace administration at the current floor. | [Namespace, auth, and workers](/docs/polyglot/namespace-auth-workers/), [Server API](/docs/polyglot/server-api-reference/) |
| Search attributes | Cloud and self-hosted Server service runtimes index typed search attributes. PHP and Python expose authoring/control surfaces; CLI and operator APIs expose structured discovery and filtering. | [Search attributes](/docs/features/search-attributes/), [Python SDK](/docs/polyglot/python/) |
| Worker compatibility | SDKs register runtime, SDK version, build ID, supported types, protocol version, and capacity; the server publishes accepted versions and routing facts. | [Compatibility](/docs/compatibility/), [Worker compatibility and routing](/docs/polyglot/worker-compatibility-routing/) |
| Local activities, worker sessions, and sticky execution | Available in embedded Laravel and the PHP service SDK. Python and Rust service workers do not yet implement these features; explicit refusal prevents incompatible routing and is not feature parity. | [Embedded activity execution](/docs/features/activity-execution-model/), [Service-mode support matrix](/docs/polyglot/portable-worker-affinity/) |
| Codec interoperability | PHP, Python, and Rust use the public `codec` + `blob` envelope and one fixed recursive Avro Value schema. Named branches preserve integers versus doubles, text versus bytes, booleans versus integers, and lists versus maps. | [Avro Value protocol](/docs/polyglot/avro-value-protocol/), [Worker protocol](/docs/polyglot/worker-protocol/), [Rust SDK](/docs/polyglot/rust/) |
| Diagnostics | Service runtimes and CLI publish version, protocol, worker, task-queue, replay, history, typed failure, and repair facts as JSON. Managed Waterline, a separately deployed self-hosted service, or the embedded package presents evidence from its owning runtime. | [Monitoring](/docs/monitoring/), [CLI reference](/docs/polyglot/cli-reference/), [Waterline operator API](/docs/waterline-operator-api/) |
| Agent tooling | Discover -> Change -> Run -> Diagnose -> Repair is available through public manifests, schemas, HTTP operations, CLI JSON, SDK clients, typed history/diagnostics, and safe mutations. MCP is one optional interface, not the definition. | [Agent tooling contract](/docs/agent-tooling-contract/), [Agent operating loop](/docs/agent-operating-loop/) |
Expand Down
3 changes: 2 additions & 1 deletion docs/features/activity-execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ keywords:
# Activity Execution Model

For service-worker support and fail-closed capability negotiation across PHP,
Python, and Rust, see [Portable Worker Affinity](/docs/features/portable-worker-affinity).
Python, and Rust, see the service-mode
[Portable Worker Affinity](/docs/polyglot/portable-worker-affinity) support matrix.

Durable Workflow v2 now has explicit primitives for the common activity
placement choices:
Expand Down
68 changes: 21 additions & 47 deletions docs/features/cancel-and-terminate.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,23 @@ sidebar_position: 16

# Cancel and Terminate

Cancel and terminate are first-class durable commands that close a running workflow. Both are recorded in command history, appear in typed history events, and surface in Waterline.

The key difference: **cancel** is a request that the workflow observe and gracefully close, while **terminate** is a direct terminal closure that does not schedule further workflow code.

## Published Rust lifecycle boundary
This guide covers the embedded Laravel `Workflow\V2\WorkflowStub` API. For
service-mode clients and workers, use the
[PHP SDK guide](https://php.durable-workflow.com/),
[Python cancellation reference](https://python.durable-workflow.com/reference/errors/#durable_workflow.errors.ActivityCancelled), or
[Rust lifecycle API](https://rust.durable-workflow.com/durable_workflow/struct.Client.html#method.cancel_workflow).

The public
[workflow-lifecycle scenario manifest](https://durable-workflow.github.io/platform-conformance/workflow-lifecycle-scenarios.json)
defines the released lifecycle evidence contract. Current validation uses
Server with the crates.io `durable-workflow` crate from the stable 2.0 release
line.

At that boundary, cancellation and termination must produce typed terminal
outcomes with workflow and run identity. Run-scoped commands must preserve
selected-run safety and reject a historical run with a typed stable reason.
Activity cancellation must be visible through heartbeat state; a late
completion must be refused after the run closes; and a worker restart during
pending cancellation must not reclaim the cancelled activity.
Cancel and terminate are first-class durable commands that close a running workflow. Both are recorded in command history, appear in typed history events, and surface in Waterline.

The exact-crate shard records Cargo registry provenance for both
`durable-workflow` and the official `apache-avro` crate used by the SDK's Avro
payload envelope. It does not substitute a custom codec or local product
source for the published payload implementation. See the
[Rust SDK lifecycle API](/docs/polyglot/rust#cancel-terminate-and-handle-terminal-outcomes)
for the selected-run commands and typed outcome variants.
In the current embedded API, both commands close the run immediately. **Cancel**
records a `cancelled` outcome; **terminate** records a `terminated` outcome.
Neither command schedules cleanup inside the closed workflow. If your
application needs durable compensation first, signal the workflow to run that
cleanup before closing it.

## Cancel

Cancel requests that a running workflow close gracefully. Cancel immediately transitions the run to `cancelled` and records durable history.
Cancel immediately transitions the run to `cancelled` and records durable history.

```php
use Workflow\V2\WorkflowStub;
Expand Down Expand Up @@ -162,29 +150,15 @@ The response includes the command outcome, the public instance id, and the reaso

## Cancellation is not an error you catch by accident

Cancellation is a control-plane outcome, not a bug. An activity or workflow that is cancelled did not fail — the caller (or an operator, or a parent workflow) asked for it to stop. To keep that signal from being swallowed by a generic catch-all, the SDKs put the cancellation exception classes **outside** the normal error hierarchy:

- **Python SDK** — `WorkflowCancelled` and `ActivityCancelled` inherit from `BaseException`, not `Exception`. A bare `except Exception:` block in an activity or result handler will **not** catch them. Catch them by name when you want to distinguish cancellation from failure:

```python
from durable_workflow import ActivityCancelled

@activity.defn(name="long_task")
async def long_task(items: list) -> dict:
ctx = activity.context()
try:
for i, item in enumerate(items):
await process(item)
await ctx.heartbeat({"progress": i + 1})
return {"done": True}
except ActivityCancelled:
await cleanup_partial_state()
raise # re-raise so the worker reports cancelled, not completed
```

- **PHP (workflow package)** — `Workflow\V2\Exceptions\WorkflowCancelledException` extends `\Error`, not `\Exception`. A `catch (\Exception $e)` block will not catch it; use `catch (\Throwable $t)` or catch the class by name.
Cancellation is an explicit lifecycle outcome, not an unexpected application
error. The embedded package's
`Workflow\V2\Exceptions\WorkflowCancelledException` extends `\Error`, not
`\Exception`. A `catch (\Exception $e)` block will not catch it; a
`catch (\Throwable $t)` block will.

This intentionally mirrors how `asyncio.CancelledError`, `KeyboardInterrupt`, and `\Error` behave in their respective standard libraries: cancellation propagates unless you handle it on purpose. If you need to run cleanup on cancellation, catch it explicitly and re-raise — don't rely on a catch-all.
When reading a cancelled workflow's result, catch the exception by name if you
need to distinguish cancellation from other failures. Catching a result-side
exception does not reopen the cancelled run or schedule cleanup inside it.

## Waterline

Expand Down Expand Up @@ -231,7 +205,7 @@ WorkflowTerminated <- failure_id, failure_category, reason when supplied

| | Cancel | Terminate |
| --- | --- | --- |
| Workflow observes the command | Yes (future cancellation scopes) | No |
| Further workflow code is scheduled | No | No |
| Open activities cancelled | Yes | Yes |
| Open timers cancelled | Yes | Yes |
| Reason metadata | Yes | Yes |
Expand Down
66 changes: 14 additions & 52 deletions docs/features/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import ConcurrencySimulator from '@site/src/components/ConcurrencySimulator';

# Concurrency

Parallel barriers describe the complete durable group before suspension. PHP
uses `all([...])` or `parallel([...])`, Python yields a list, and Rust awaits
`WorkflowContext::parallel(...)` or `join(...)`. Every SDK emits ordinary
activity, child-workflow, or timer commands with the same group identity/path
metadata; no separate parallel wire command exists. Results come back in the
original nested input shape.
This guide covers concurrency in embedded Laravel workflows using
`Workflow\V2\Workflow`. For service-mode workers, use the
[PHP SDK guide](https://php.durable-workflow.com/build/workflows-activities/),
[Python SDK guide](https://python.durable-workflow.com/sdk-reference/#deterministic-parallel-groups), or
[Rust SDK reference](https://rust.durable-workflow.com/durable_workflow/struct.WorkflowContext.html#method.parallel).

`all([...])` describes the complete durable group before suspension. The
embedded runtime schedules its activity and child-workflow commands and returns
results in the original nested input shape.

Use a selection group when progress depends on the first completed member
instead of the whole barrier. Selection is also durable: it starts every member,
Expand Down Expand Up @@ -88,8 +91,7 @@ The main difference between the serial example and the parallel execution exampl
waits, condition waits, or nested ordinary barriers and resumes when one member
commits an eligible result or typed failure. Give members stable application
keys when later code needs to distinguish or revisit them.
Member keys have one portable domain across runtimes: a non-empty string or a
non-negative integer.
Member keys must be a non-empty string or a non-negative integer.

The following coordinator starts its deadline at the same durable step as the
resolver. Input processing and resolver progress cannot reset or postpone that
Expand Down Expand Up @@ -147,11 +149,6 @@ idempotent. Concurrent inputs are ordered by their committed durable history;
an input that arrives while an activity runs is visible on the next workflow
task, and a late or duplicate input cannot replace an already recorded winner.

Service-mode SDKs expose the same lifecycle in their language model: Python
uses `yield ctx.select({...})`, PHP uses `$ctx->select([...])`, and Rust awaits
`ctx.select_keyed(...)`. See the [PHP service-mode example](/docs/polyglot/php/#run-a-remote-php-worker)
and the language pages for exact handle methods.

## Nested Barriers

Nested `all([...])` groups let one workflow step express a tree of durable fan-out and fan-in work. The runtime schedules every activity or child workflow as a durable leaf sequence, records the leaf's full `parallel_group_path`, waits until every enclosing barrier can make progress, and then rebuilds the original nested result shape before resuming the workflow body. During replay, an activity or child leaf from an `all([...])` step must still match that recorded group path; typed leaf history that has no group metadata is treated as incompatible older preview history instead of being guessed into the current barrier.
Expand All @@ -177,52 +174,17 @@ final class NestedWorkflow extends Workflow

In that example, Waterline exposes three open leaf waits, not one synthetic "nested" wait. The first leaf belongs only to the outer barrier, while the second and third leaves expose a two-entry `parallel_group_path` so operators can see both the outer group and the inner subgroup that is still open.

### Python nested list-yield

```python
results = yield [
ctx.schedule_activity("build-summary", []),
[
ctx.start_child_workflow("build-invoice", []),
ctx.start_timer(1),
],
]
summary, (invoice, _) = results
```

### Rust nested join

```rust
use durable_workflow::{json, ChildWorkflowOptions, ParallelOperation};
use std::time::Duration;

let results = ctx.join(vec![
ParallelOperation::activity("build-summary", json!([])),
ParallelOperation::group(vec![
ParallelOperation::child_workflow(
"build-invoice",
ChildWorkflowOptions::new("document-workers"),
json!([]),
),
ParallelOperation::timer(Duration::from_secs(1)),
]),
]).await?;
```

For all three SDKs, the outer group size counts durable leaves, not list nodes.
The outer group size counts durable leaves, not nested arrays.
Each nested leaf carries an outer-to-inner `parallel_group_path`; every path
entry preserves the same durable workflow position. The group schedules all
leaves before it suspends, then assembles successful values by input position.
Worker restart and completed-history replay rebuild the same group identity.
Exact duplicate terminal delivery is ignored, and a late sibling completion
can enrich partial diagnostics without changing the failed member already
selected by the SDK's deterministic policy.
selected by the embedded runtime's deterministic policy.

Python throws the typed leaf failure at the list-yield expression. Rust wraps
the typed cause in `Error::ParallelFailed` together with the failed member path,
full group path, and already completed siblings. PHP raises the typed leaf
failure from `all()` and retains the barrier's durable group metadata in
history and operator views.
The embedded runtime raises the typed leaf failure from `all()` and retains
the barrier's durable group metadata in history and operator views.

## Async Callback

Expand Down
67 changes: 11 additions & 56 deletions docs/features/sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ sidebar_position: 11

# Sagas

This guide covers compensation in embedded Laravel workflows using
`Workflow\V2\Workflow`. For service-mode workers, use the
[PHP SDK guide](https://php.durable-workflow.com/build/workflows-activities/),
[Python SDK guide](https://python.durable-workflow.com/sdk-reference/#saga-compensation), or
[Rust SDK reference](https://rust.durable-workflow.com/durable_workflow/struct.Saga.html).

Sagas are an established design pattern for managing complex, long-running operations:

- A saga manages distributed transactions using a sequence of local transactions.
Expand Down Expand Up @@ -43,70 +49,19 @@ class BookingSagaWorkflow extends Workflow

When the workflow catches an exception, `$this->compensate()` runs every registered compensation in **reverse order**. In the example above, if `BookRentalCarActivity` fails, the engine cancels the hotel first and then the flight — unwinding the saga from the most recent step backward.

The service-mode Python and Rust SDKs expose the same default policy through
language-native helpers. They reuse ordinary activity commands and history;
there is no saga-specific wire command.

### Python

```python
def forward(saga):
flight = yield ctx.schedule_activity("trip.reserve-flight", [])
saga.add_compensation("trip.cancel-flight", [flight])

hotel = yield ctx.schedule_activity("trip.reserve-hotel", [])
saga.add_compensation("trip.cancel-hotel", [hotel])

ctx.throw_if_cancellation_requested()
yield ctx.schedule_activity("trip.charge", [])
return {"status": "booked"}

return (yield from ctx.saga().run(forward))
```

### Rust

```rust
let mut saga = ctx.saga();
let outcome = async {
let flight = ctx.activity("trip.reserve-flight", json!([])).await?;
saga.add_compensation("trip.cancel-flight", json!([flight]))?;

let hotel = ctx.activity("trip.reserve-hotel", json!([])).await?;
saga.add_compensation("trip.cancel-hotel", json!([hotel]))?;

ctx.throw_if_cancellation_requested()?;
ctx.activity("trip.charge", json!([])).await?;
Ok(json!({"status": "booked"}))
}.await;

saga.finish(outcome).await
```

Registering after forward success is part of the deterministic contract. A
restart replays the same registration order and resumes the next uncompensated
activity. Exact duplicate completion delivery does not run a compensation
twice. Cooperative cancellation is observed only where workflow code calls the
SDK cancellation check, so authors choose a safe point after registering any
cleanup that must run.
Register each compensation after its forward activity succeeds. Replay
reconstructs that registration order and reuses recorded activity results,
including completed compensations.

## Compensation ordering

By default, compensations execute **sequentially in reverse registration order**. This is the safest default because later steps may depend on earlier ones.

Python and Rust stop at the first compensation failure. Their
`SagaCompensationFailed` / `Error::SagaCompensationFailed` diagnostics preserve
both the initiating failure and compensation failure, plus the failed
compensation's activity type and registration order. PHP's default also stops
and propagates on the first compensation failure; its runtime records the
initiating and compensation diagnostics together.
By default, `compensate()` stops at the first compensation failure and
propagates it to the caller.

## Parallel compensation

Parallel compensation and continue-on-error are PHP-specific opt-in policies
at the current SDK floors. Python and Rust intentionally expose only the shared
sequential, reverse-order, stop-first policy.

To run compensations in parallel, use `setParallelCompensation(true)`. When parallel compensation is enabled, each compensation closure should return a started (but not awaited) activity call so the engine can execute them concurrently:

```php
Expand Down
Loading