From 59b09cdad2bdd92c3a5fe9bd378fd2ae0bc7b927 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Mon, 7 Sep 2026 15:51:25 +0000 Subject: [PATCH 1/2] docs: separate embedded features from service-mode SDK guidance --- AGENTS.md | 6 ++ docs/capabilities.md | 1 + docs/features/activity-execution-model.md | 3 +- docs/features/cancel-and-terminate.md | 68 ++++++------------- docs/features/concurrency.md | 65 ++++-------------- docs/features/sagas.md | 67 +++--------------- .../portable-worker-affinity.md | 24 ++++--- docusaurus.config.js | 7 ++ sidebars.js | 2 +- src/css/custom.css | 10 +-- 10 files changed, 83 insertions(+), 170 deletions(-) rename docs/{features => polyglot}/portable-worker-affinity.md (79%) diff --git a/AGENTS.md b/AGENTS.md index 82b1c4f980..a0e49d3cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/capabilities.md b/docs/capabilities.md index 2f968b4b7e..46b1a1a561 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -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/) | diff --git a/docs/features/activity-execution-model.md b/docs/features/activity-execution-model.md index c25686e4b6..77857702b1 100644 --- a/docs/features/activity-execution-model.md +++ b/docs/features/activity-execution-model.md @@ -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: diff --git a/docs/features/cancel-and-terminate.md b/docs/features/cancel-and-terminate.md index aec5200114..320c7c8cd1 100644 --- a/docs/features/cancel-and-terminate.md +++ b/docs/features/cancel-and-terminate.md @@ -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; @@ -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 @@ -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 | diff --git a/docs/features/concurrency.md b/docs/features/concurrency.md index ad5f5d1703..418b32e567 100644 --- a/docs/features/concurrency.md +++ b/docs/features/concurrency.md @@ -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, @@ -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 @@ -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. @@ -177,51 +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 +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 diff --git a/docs/features/sagas.md b/docs/features/sagas.md index 876426638f..24a5db3bd4 100644 --- a/docs/features/sagas.md +++ b/docs/features/sagas.md @@ -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/#compensate-completed-work-with-a-saga), +[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. @@ -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 diff --git a/docs/features/portable-worker-affinity.md b/docs/polyglot/portable-worker-affinity.md similarity index 79% rename from docs/features/portable-worker-affinity.md rename to docs/polyglot/portable-worker-affinity.md index 34eadbcfd4..1bb465247f 100644 --- a/docs/features/portable-worker-affinity.md +++ b/docs/polyglot/portable-worker-affinity.md @@ -1,7 +1,7 @@ --- sidebar_position: 26 title: Portable Worker Affinity -description: Negotiate local activities, worker sessions, and sticky execution safely across PHP, Python, and Rust service workers. +description: Service-mode worker-affinity support and limitations for PHP, Python, and Rust SDKs. tags: - workers - activities @@ -11,6 +11,10 @@ tags: # Portable Worker Affinity +This is a service-mode capability reference, not an embedded Laravel feature +guide. PHP service workers implement these features; Python and Rust service +workers do not yet implement them. + Local activities, worker sessions, and sticky execution share one portability rule: a service worker must declare each feature as supported or explicitly refused. Protocol version `1.18` is the floor for these declarations. The @@ -22,12 +26,13 @@ manifest marks the same feature as supported. | SDK worker | Local activities | Worker sessions | Sticky execution | | --- | --- | --- | --- | | PHP | Supported | Supported | Supported | -| Python | Explicitly refused | Explicitly refused | Explicitly refused | -| Rust | Explicitly refused | Explicitly refused | Explicitly refused | +| Python | Not supported | Not supported | Not supported | +| Rust | Not supported | Not supported | Not supported | -An explicit refusal is safe interoperability, not partial execution. The -worker remains usable for its ordinary workflow and activity capabilities and -is never routed work that depends on the refused contract. +Python and Rust advertise `supported: false` for all three features. That +prevents incompatible routing; it is not feature parity or a fallback +implementation. These workers remain usable for ordinary workflows and queued +activities, with complete durable-history replay. ## Local activity recording @@ -81,7 +86,8 @@ covers manifest truth, local-activity replay, session holder loss and reacquisition, sticky hits and eviction, worker replacement, forced cold replay, and zero-configuration workflows. -See [Local Activities](/docs/features/local-activities), +For embedded Laravel implementations, see +[Local Activities](/docs/features/local-activities), [Worker Sessions](/docs/features/worker-sessions), and -[Sticky Execution](/docs/features/sticky-execution) for the individual -contracts. +[Sticky Execution](/docs/features/sticky-execution). Those APIs belong to the +workflow package, not to Python or Rust service workers. diff --git a/docusaurus.config.js b/docusaurus.config.js index a2137ab3c4..f3a2f8e84e 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -117,6 +117,13 @@ const config = { return undefined; }, redirects: [ + { + from: [ + '/docs/features/portable-worker-affinity', + '/docs/2.0/features/portable-worker-affinity', + ], + to: '/docs/polyglot/portable-worker-affinity', + }, { from: '/docs', to: '/docs/introduction/', diff --git a/sidebars.js b/sidebars.js index b6bfa0ffcf..2311d1e6c3 100644 --- a/sidebars.js +++ b/sidebars.js @@ -50,6 +50,7 @@ const sidebars = { 'polyglot/server-role-topology', 'polyglot/namespace-auth-workers', 'polyglot/worker-compatibility-routing', + 'polyglot/portable-worker-affinity', 'polyglot/embedded-to-server', ], }, @@ -109,7 +110,6 @@ const sidebars = { 'features/versioning', 'features/external-payload-storage', 'features/activity-execution-model', - 'features/portable-worker-affinity', 'features/local-activities', 'features/worker-sessions', 'features/sticky-execution', diff --git a/src/css/custom.css b/src/css/custom.css index bdd0cd3c2f..e1fe429f7a 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -62,18 +62,18 @@ a[class^='skipToContent']:focus { /* Keep the mobile table-of-contents control reachable when the SDK support * section is centered below the fixed navbar. */ -[class~='docs-doc-id-features/portable-worker-affinity'] #sdk-support { +[class~='docs-doc-id-polyglot/portable-worker-affinity'] #sdk-support { scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem); } -[class~='docs-doc-id-features/portable-worker-affinity'] #sdk-support + table { +[class~='docs-doc-id-polyglot/portable-worker-affinity'] #sdk-support + table { display: table; width: 100%; table-layout: fixed; overflow: visible; } -[class~='docs-doc-id-features/portable-worker-affinity'] +[class~='docs-doc-id-polyglot/portable-worker-affinity'] #sdk-support + table :is(th, td) { min-width: 0; overflow-wrap: normal; @@ -82,11 +82,11 @@ a[class^='skipToContent']:focus { } @media (max-width: 996px) { - [class~='docs-doc-id-features/portable-worker-affinity'] #sdk-support { + [class~='docs-doc-id-polyglot/portable-worker-affinity'] #sdk-support { scroll-margin-top: calc(var(--ifm-navbar-height) + 6.25rem); } - [class~='docs-doc-id-features/portable-worker-affinity'] + [class~='docs-doc-id-polyglot/portable-worker-affinity'] #sdk-support + table :is(th, td) { font-size: 0.875rem; padding: 0.5rem 0.35rem; From 6f597f6caefde1a5d0a66a7bc493e33c5b82ae1c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Mon, 7 Sep 2026 15:53:47 +0000 Subject: [PATCH 2/2] docs: link to the published PHP saga guide without an unavailable fragment --- docs/features/concurrency.md | 3 +-- docs/features/sagas.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/features/concurrency.md b/docs/features/concurrency.md index 418b32e567..0329b76ea9 100644 --- a/docs/features/concurrency.md +++ b/docs/features/concurrency.md @@ -184,8 +184,7 @@ can enrich partial diagnostics without changing the failed member already selected by the embedded runtime's deterministic policy. The embedded runtime raises the typed leaf failure from `all()` and retains -the barrier's durable group metadata in -history and operator views. +the barrier's durable group metadata in history and operator views. ## Async Callback diff --git a/docs/features/sagas.md b/docs/features/sagas.md index 24a5db3bd4..02e870c426 100644 --- a/docs/features/sagas.md +++ b/docs/features/sagas.md @@ -6,7 +6,7 @@ sidebar_position: 11 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/#compensate-completed-work-with-a-saga), +[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).