Pattern — Saga
The problem it solves
You have to change state in more than one place — two databases during a migration, or inventory + payments + shipping across three services — and there is no single transaction that spans them. The naive answer is a dual write: write store A, then write store B, and hope both land. They don't. In Drill 55 — The Dual-Write Drift, a months-long migration wrote every record to the old store and the new one, with the new-store write wrapped in a catch block "so the migration can never hurt production." A serialization bug silently failed about 2% of new-store writes; the old store served all reads and looked perfect, so five months of divergence accumulated invisibly and surfaced as customers missing payment history. The deeper failure shape: a multi-step distributed write has no atomicity, so a partial completion is a legal, silent, unrecoverable outcome — step 1 commits, step 2 fails, and nothing on the system knows the operation is half-done.
The pattern
A saga models a distributed operation as a sequence of local ACID transactions, where each step has a matching compensating transaction that semantically undoes it. A durable saga log records which steps have committed; a coordinator drives every saga to a terminal state — either all steps commit (forward completion) or, on failure, the already-committed steps are undone in reverse (backward recovery). You give up cross-store isolation — a saga is ACD without the I, so intermediate states are briefly visible — in exchange for the one guarantee dual-write can't give: no operation is left half-applied and unaccounted for. The swallowed catch block becomes a persisted PENDING state that a recovery process is obligated to resolve.
How to apply it
Grounding the mechanics in Drill 55 (model "write old store, then new store" as a two-step saga; the general form uses reserve-inventory → charge-payment → create-shipment):
- Persist saga state before every step, not after. Write a row to
saga_instances(saga_id, type, current_step, status, payload)and flipstatusandcurrent_stepinside the same local transaction as each step's business write. Status is a strict machine:STARTED → PENDING(step_i) → COMPLETEDor→ COMPENSATING → COMPENSATED / FAILED. If the process dies mid-step, the log — not memory — is the source of truth on restart. - Prefer orchestration for anything over ~3 steps. A central orchestrator (Temporal, Step Functions) owns the state machine, calls each step, and is trivially auditable — you can query "how many sagas are stuck at step 2." Choreography (each service reacts to the previous one's event, no central brain) has lower coupling but the workflow is emergent and hard to trace; reserve it for 2-participant flows.
- Bound each step and retry the retriable ones. Give each step a 30 s timeout and retry transient failures with exponential backoff — base 1 s, ×2, ±20% jitter, max 5 attempts. Steps must be idempotent (carry a step key) because a retry may replay a write that actually succeeded.
- On exhausted retries, run backward recovery. Execute the compensations for every committed step in reverse order (
release-inventory←refund-payment←cancel-shipment). Compensations must themselves be idempotent and retried effectively forever — a compensation that gives up leaves exactly the orphaned half-state you were trying to prevent. Retry a failing compensation with backoff up to 24 h, then route to a DLQ and page a human. - Name the pivot transaction. The step after which the saga must only go forward (a compensation is impossible or undesirable — an email is already sent, funds already captured). Before the pivot, failure ⇒ compensate; after it, failure ⇒ retry-forward until it completes.
- Add a countermeasure for the missing isolation. Because other readers can see a saga's intermediate state, apply a semantic lock: mark touched records
status = PENDINGso readers skip or wait on them, and clear it at terminal state. Alternatives: commutative updates, or re-read-then-check (optimistic version guard). - Run a reaper. A sweeper scans every 60 s for sagas in a non-terminal state older than their step SLA and resumes or compensates them — this is the standing job that turns "silently stuck" into "detected and driven to done," the direct analog of Drill 55's reconciliation-until-zero.
flowchart TD
A["Start saga · persist STARTED"] --> B["Step 1: reserve inventory<br/>(local txn + log)"]
B --> C["Step 2: charge payment<br/>(local txn + log)"]
C -->|"success"| D["Step 3: create shipment<br/>= pivot, go forward only"]
C -->|"fail after 5 retries"| E["COMPENSATING: refund,<br/>then release inventory"]
E --> F["COMPENSATED (terminal)"]
D --> G["COMPLETED (terminal)"]
H["Reaper every 60s: resume or<br/>compensate stuck sagas"] -.-> C
When to use it — and when not
Reach for a saga when a business operation must span stores or services that no single ACID transaction covers, and correctness depends on the whole thing either completing or unwinding. Do not reach for it when:
- The writes fit in one local transaction. If old and new store were the same database, a
BEGIN … COMMITgives you real atomicity and isolation for free — a saga would add a state machine, compensations, and a reaper to buy a strictly weaker guarantee. Don't distribute what fits in one transaction. - You need strict isolation. A saga has no I — its intermediate states are visible, so a
PENDINGorder can be read as real. If a partially-applied window is unacceptable and you can't paper over it with a semantic lock, a saga is the wrong tool. - A step is genuinely irreversible and can't be a pivot. If you cannot write a sane compensation (you can't un-send a physical shipment or un-fire a missile), the saga can't unwind past it — restructure so all irreversible steps come last, or accept manual remediation.
- Two participants with a durable outbox already suffice. For a single downstream effect, a transactional outbox + idempotent consumer is simpler than a full orchestrator. Sagas earn their complexity at 3+ steps.
The cost is real: you write and test a compensation for every step, you live with eventual consistency and its visible intermediate states, and you operate a coordinator and a reaper.
Signals it's working
- Every saga reaches a terminal state. The count of sagas non-terminal for longer than their SLA (e.g. 5 min) holds at 0 — this is the reconciliation-until-zero gate: nonzero is a stop-ship, published where the team sees it daily, alarmed on any nonzero.
orphaned-compensations = 0andhalf-applied writes = 0— no committed step without either its forward completion or its compensation.- Compensation rate is tracked and bounded; a rising compensation rate flags a flaky downstream, not a correctness bug (the unwind is working — that's the point).
- p99 saga duration and reaper-resumes/hour are graphed; a spike in reaper resumes means steps are dying mid-flight.
- A crash-recovery chaos test passes in CI: kill the orchestrator between step 2's write and its log flush, restart, and assert the saga resumes from the log and reaches a terminal state with each effect and each compensation applied exactly once.
Seen in the wild
- AWS Step Functions publishes the "saga pattern" as a reference architecture — a state machine with explicit compensation branches.
- Temporal / Uber Cadence and Azure Durable Functions persist workflow state and replay it after a crash — sagas as durable code.
- Camunda Zeebe (BPMN) and Netflix Conductor are orchestration engines built for exactly this; Eventuate Tram Sagas and MassTransit / NServiceBus routing slips implement it for .NET/Java services.
- The pattern is old and load-bearing: Garcia-Molina & Salem's 1987 "Sagas" paper named it, for long-lived transactions that can't hold locks.
See it
The ChainScene carries the mechanism — reserve → charge → (fail) → compensate back in reverse to a COMPENSATED terminal state — and the TilesScene shows the load-bearing config (30 s step timeout, max 5 retries with backoff, 60 s reaper sweep, compensations retried to 24 h → DLQ).
Named concepts: saga pattern, compensating transactions, saga orchestration, backward recovery. </content> </invoke>
