Distributed Transactions: 2PC, Sagas, and the Outbox32 min read
Module A-3·32 min read
Two-phase commit and its blocking problem, sagas with compensation — and what you do when compensation itself fails and the email has already gone out.
Module A-3 — Distributed Transactions: 2PC, Sagas, and the Outbox
What this module covers: What you lose when one business operation has to change state in several places and there is no transaction that spans them. Two-phase commit in enough detail to see why its blocking problem is fatal rather than inconvenient — a coordinator crash leaves participants holding locks they are forbidden to release — and the narrow cases where 2PC is still the right answer. Sagas as the alternative, orchestrated or choreographed, and the thing sagas are usually taught without: they abandon isolation, so half-finished business operations are visible to everyone, and the countermeasures for that are part of the design rather than a detail. Compensation as a forward transaction that approximates undo rather than a rollback. Then the section most treatments omit: compensation itself fails. Non-compensable steps, the pivot transaction that must therefore come last, unrecoverable saga states, and why the actual safety net is an operator queue with real tooling rather than a cleverer algorithm. Finally the outbox, which does not give you a distributed transaction but removes the need for one in the most common case.
The Problem: One Operation, Several Places to Fail
Module P-1 gave you ACID inside one database. A single COMMIT makes five table changes atomic, isolated, and durable, and the database does all the work. Then the operation grows:
A customer places an order. Charge their card, decrement inventory, create a shipment, award loyalty points, and email a receipt.
Five state changes across a payment provider, an inventory service, a shipping service, a loyalty service, and an email provider. There is no COMMIT that covers them. And the failure modes are not hypothetical:
text
Every partial-failure combination of five steps is a state your system can be in, and most of them are states nobody designed. The question this module answers is what to do about that — and the first honest observation is that the most common right answer is to not need atomicity at all. Before reaching for either mechanism below, ask what actually breaks if the loyalty points arrive four seconds later, or if the receipt email lags the charge. Usually: nothing. What is genuinely required is that no step is lost, and that is a much cheaper guarantee than atomicity.
Analogy: a house purchase. There is no instant in which the money and the deeds change hands simultaneously — there is a chain of steps with a conveyancer tracking state, an escrow account holding funds in an intermediate condition everyone can see, and a defined procedure for what happens if the survey fails at step four. Two things about that process are worth carrying forward. The intermediate state is visible and named ("under offer"), rather than pretended away. And once the money is released there is no undo, only a new legal action to try to get it back — which is why the release comes as late in the chain as possible.
Two-Phase Commit, and Why Its Blocking Problem Is Fatal
2PC is the textbook answer, and it does provide genuine atomicity. A coordinator drives two rounds:
text
The guarantee is real: if every participant promises, every participant commits. And the promise is the whole mechanism — a participant that said YES has given up the right to decide for itself, which is exactly why it cannot recover on its own.
The blocking problem. The coordinator crashes after collecting YES votes and before sending the decision. Every participant is now in-doubt: it has locks held, changes written but not committed, and it may not abort (it promised) nor commit (it was not told to). It must wait for the coordinator to come back. Indefinitely.
What "wait indefinitely" means operationally is worse than it sounds:
Rows are locked, so unrelated transactions touching them block, and Module P-4's connection pool fills with waiters.
In Postgres, a prepared transaction holds its transaction ID, so vacuum cannot clean up anywhere in the database (Module F-13) — a forgotten prepared transaction is a slow, cluster-wide degradation that ends in transaction-ID wraparound protection shutting writes down.
Recovery requires a human to inspect and force a decision, which risks committing one participant and aborting another — the exact atomicity violation the protocol existed to prevent.
The other costs, briefly, because they matter even when nothing crashes:
Cost
Detail
Latency
two round trips plus a durable fsync per participant, per transaction
Availability
the product of all participants' availability — five services at 99.9% gives 99.5%, and any one being down blocks every commit
Coupling
all participants must be up, reachable, and mutually trusting simultaneously
Scope
needs XA or an equivalent protocol; an HTTP API cannot be a participant, which excludes most of a modern architecture
Partition behaviour
it blocks — 2PC gives up availability completely, in CAP terms (Module P-10)
Where 2PC is nevertheless correct and used every day:
Inside one database across many tables — which is just a transaction, and is the case people forget counts.
Kafka transactions (Module P-15), where the coordinator is a highly-available Kafka component and the scope is Kafka-to-Kafka.
A single-vendor cluster on a fast local network with a replicated coordinator, short-lived transactions, and an operator who knows what pg_prepared_xacts is.
What makes it wrong for service-to-service work is not that the algorithm is bad — it is correct — but that its failure mode is "hold locks until a human intervenes," which in a system of independently-deployed services with rolling restarts is a recurring event rather than a rare one.
Sagas: Give Up Atomicity, Keep Progress
A saga splits the operation into a sequence of local transactions, each of which commits independently, with a compensating action defined for each step that has already committed.
text
Every step commits immediately, so nothing is ever held in doubt and no locks span services. In exchange you accept that the operation is only eventually complete or eventually undone.
Orchestration or choreography
Choreography: each service publishes events and others react. Order service publishes OrderPlaced; payment reacts and publishes PaymentCaptured; inventory reacts to that. No central controller.
Orchestration: a coordinator holds the saga's state machine and explicitly invokes each step, deciding what comes next and what to compensate.
Choreography
Orchestration
Where the flow lives
implicit, spread across N services
one explicit state machine
"What is the current state of order 4417?"
assemble it from event logs
read one row
Coupling
services couple to each other's events
services couple to the coordinator
Adding a step
deploy a new subscriber
change the coordinator
Cyclic-dependency risk
high, and easy to create by accident
low
Testability
hard — behaviour is emergent
straightforward
Compensation logic
scattered, and easily incomplete
centralised, reviewable
Choreography is attractive for two or three steps with no money involved. Beyond that, orchestrate — because the question "which sagas are currently stuck, and where?" must have a cheap answer, and in a choreographed system it does not. That question is the subject of this module's most important section.
Whichever you choose, three properties are mandatory:
The saga's state is durable — a row with the current step, the accumulated context, and a state machine advanced by compare-and-set (Module P-24's mechanism).
Every step is idempotent (Module P-16), because every step will be retried.
Every step is retried with backoff before it is treated as failed (Module A-6), since most step failures are transient and compensating a five-step saga because of one 503 is enormously wasteful.
The thing sagas give up that nobody mentions: isolation
Atomicity is the loss people know about. Isolation is the loss that causes bugs. In a saga, T1 has committed and is visible to the entire system while T2 is still running. So:
A customer's card is charged and their order shows as unpaid for four seconds.
Inventory is decremented, then released — and in between, a report was generated with the wrong stock figure.
Two concurrent sagas both read "available: 1" and both reserve it.
A compensation runs after another transaction has already acted on the intermediate state.
These are, in database terms, dirty reads and lost updates — reintroduced at the architecture level after Module P-1 taught you how to prevent them. The countermeasures are known and belong in the design:
Countermeasure
How
Cost
Semantic lock
an explicit pending / reserved status that other operations must respect
every reader must understand the state; needs a timeout sweeper for abandoned pendings
Commutative updates
design steps so order does not matter (append an entry rather than set a balance — Module P-24)
not always expressible
Re-read and validate
verify preconditions again at the moment of the pivotal step
a race window remains, just a smaller one
Reordering
put the step most likely to fail first, so less is visible when it does
sometimes conflicts with the pivot rule below
Version checks
compare-and-set on the version read earlier
the losing saga must handle a conflict
The semantic lock is the one to reach for first, and its cost is worth stating plainly: introducing a pending state means every consumer of that data must now handle it. A report that counts pending orders as real, or a UI that shows a pending charge as complete, is the isolation failure appearing as a product bug rather than a data bug.
Compensation Is a Forward Transaction, Not a Rollback
A database rollback erases history: after it, nothing happened. A compensation is a new transaction that attempts to reach a state resembling the original one, and the difference is not pedantic.
Step
"Undo"
What actually happens
Charge £42
refund £42
a refund transaction; the fee may not be returned, FX may differ, and both appear on the customer's statement
Reserve stock
release stock
someone else may have taken it in between
Create shipment
cancel shipment
if it left the warehouse, this is a return, with a cost
Award points
deduct points
if the customer already spent them, the balance goes negative
Send receipt
—
nothing. There is no un-send.
So compensation is approximate, observable, and sometimes impossible. Each of those three properties has a design consequence, and the third is the one that reshapes the whole saga.
The Part Usually Left Out: Compensation Fails Too
Sagas are almost always presented as if compensation succeeds. It does not, and the resulting states are where real systems get stuck.
Non-compensable steps, and the pivot transaction
Some effects cannot be undone at all: an email or SMS delivered, a webhook fired to a customer's system (Module P-17), a message posted publicly, a physical parcel handed to a courier, a report filed with a regulator.
This gives the single most useful structural rule in saga design. Divide the saga into three zones:
text
Compensatable steps come first. Each has a working compensation, so a failure anywhere in this zone unwinds cleanly.
The pivot is the point of no return — the step after which the saga must complete. Committing it is the decision that the operation will happen.
Retriable steps come last. They have no compensation and therefore must be retried until they succeed, with no bounded attempt limit that gives up silently. They must also be designed so that success is eventually achievable — which means they cannot have preconditions that can become permanently false.
Ordering a saga this way is a design act, and it frequently conflicts with the instinct to "do the cheap things first." Sending the receipt email before capturing payment feels harmless and makes the saga uncompensatable from step one. Every non-compensable step belongs after the pivot.
Unrecoverable saga states
Even with correct ordering, compensation can fail:
Transiently — the payment provider is down. Retry with backoff; this is the easy case and most failures are this.
Permanently — the refund is rejected because the customer's card is closed or expired. The inventory you released was already sold to somebody else. The shipment cannot be cancelled because it is on a van. The loyalty points were spent.
Now the saga can neither go forward (the failing step will not succeed) nor backward (the compensation will not succeed). This state is real, it happens at a low but non-zero rate, and there is no algorithm that resolves it. Somebody has to decide: refund by bank transfer instead, write off the stock, issue a credit note, contact the customer.
The actual safety net is operator tooling
Which produces the honest conclusion of this module: the mechanism that makes sagas safe in production is not the saga pattern. It is the human-in-the-loop dead-letter queue and the tooling around it, and that tooling is a first-class deliverable rather than a follow-up ticket.
What it must provide:
A queryable list of stuck sagas, with an alert on count and on age of the oldest — the same metric that matters for jobs (Module P-19), for the same reason.
The full saga record: every step attempted, its outcome, its error, its timestamps, the accumulated context, and the compensations attempted. An operator who has to reconstruct this from logs will not do it correctly at 3 a.m.
Safe operator actions: retry a single step, force-compensate a step, skip a step with a recorded reason, mark the saga resolved manually. Each action recorded, attributable, and — critically — each idempotent, because an operator will click twice.
A financial escape hatch: the ability to write a reversing ledger entry with a reason (Module P-24), because when compensation is impossible the resolution is usually an accounting one.
Reconciliation comparing your saga outcomes against the downstream systems' own records, because a saga that believes it completed and a payment provider that disagrees is a break you will only find by looking.
Why this matters in production: a saga implementation without this tooling appears to work. The happy path is fine, transient failures retry successfully, and the compensation path was tested. What happens instead is that a handful of sagas per week enter unrecoverable states, nobody notices because nothing alerts on them, and the discovery comes weeks later from a customer or from finance — at which point resolution is a support engineer writing UPDATE statements against production, which is both the least safe and most expensive possible remediation. Build the operator queue with the saga, not after it.
Durable execution engines are worth knowing about
A category of tooling — Temporal, Restate, AWS Step Functions, and similar — exists specifically to remove the boilerplate here. What they give you is durable execution: the orchestrator's control flow is itself persisted, so you write what looks like ordinary sequential code with awaits, and the engine records each step's completion, replays after a crash, and handles retries, timeouts and compensation hooks.
ts
The costs are real: an additional stateful system to operate, a programming model with constraints (your workflow code must be deterministic and side effects must live in activities), and a learning curve. They do not remove the need for the pivot ordering or the operator queue — but they do remove the hand-rolled state machine, the retry bookkeeping, and roughly the top half of the bugs in this module.
The Outbox: Not a Distributed Transaction, But It Removes the Need for One
A large share of "we need a distributed transaction" requirements are actually the shape update my own state and tell the world. That does not need atomicity across services — it needs one local write and a guaranteed publish.
The dual write is broken, for the fourth time in this course (Modules P-16, P-19, P-20, P-23):
ts
The outbox makes it one transaction, because the event is written to a table in the same database:
sql
A relay then publishes outbox rows — either a poller (simple, adds latency and load) or, better, log-based CDC over the outbox table (Module P-23), which gives you no polling, no relay process to operate, and an insert-only source with no before-image or TOAST complications.
Delivery is at-least-once, so the receiving side needs an inbox: a table with UNIQUE (event_id) where consumers deduplicate before processing (Module P-16, Module P-17). Outbox on one side, inbox on the other, and the pair gives you exactly-once effect over at-least-once delivery.
Two details that make or break it. Ordering is per aggregate, so partition on aggregate_id and accept that events for different orders may interleave. And the outbox table is high-churn — insert, publish, delete — so either partition it by day and drop partitions, or let CDC read it and delete in batches, because otherwise it becomes the bloat pattern from Module P-9.
The outbox does not give you atomicity across services. What it gives you is the guarantee that no step is ever lost, which — per the opening of this module — is what the requirement usually was.
Choosing
Situation
Mechanism
All changes in one database
one transaction. Stop here.
Update local state and notify others
outbox (+ inbox on consumers)
Multi-step business process, compensations exist
orchestrated saga with pivot ordering
Multi-step, 2–3 steps, no money
choreography via events
Kafka-to-Kafka read-process-write
Kafka transactions (Module P-15)
Two tables in one vendor's cluster, short-lived, HA coordinator
2PC is defensible
Across independent services over HTTP
never 2PC
Complex workflow, many steps, long-running
a durable execution engine
And the question to ask before any of them: does this actually need to be atomic, or does it need to not lose anything? The second is far cheaper, and it is usually the real requirement.
Where This Shows Up in Your Stack
PostgreSQL:max_prepared_transactions defaults to 0, and that default is a deliberate warning. A prepared transaction that is never resolved holds locks and pins its transaction ID, so vacuum cannot advance anywhere in the cluster (Module F-13) — this is one of the few ways to degrade an entire Postgres instance with a single forgotten statement, ending in wraparound protection refusing writes. If you enable it, alert on pg_prepared_xacts with any row older than minutes. The outbox is the pattern to use instead, and SELECT … FOR UPDATE SKIP LOCKED is how the relay claims rows (Module P-19).
Node.js: a saga hand-rolled as promise chains with try/catch compensation loses all its state on process exit, which is the failure the whole module is about — the state machine must be in the database, not the call stack. Durable execution engines exist precisely to fix this; if you hand-roll, make the step table the source of truth and the code a stateless driver over it.
Kafka: transactions give exactly-once for Kafka-to-Kafka pipelines, and nothing beyond that boundary — a card charge inside a consumer is outside the transaction and can repeat. Partition the outbox topic on aggregate_id for per-entity ordering, and remember that consumer rebalances mean a saga step handler can run twice (Module P-15).
Redis: not a participant in any of this. MULTI/EXEC is not 2PC and offers no rollback on logical failure, and Redis's failover semantics (Module A-1) make it unsuitable as the durable store for saga state.
Payment providers and third-party APIs: cannot participate in 2PC and often cannot be compensated exactly (fees, FX, closed cards). This is what forces the pivot-transaction discipline, and it is why Module P-24's reconciliation exists.
Summary
Before choosing a mechanism, ask whether atomicity is actually required. Usually the requirement is that no step is lost, and that is a much cheaper guarantee than "all or nothing."
2PC is correct and its failure mode is fatal. A participant that votes YES surrenders the right to decide, so a coordinator crash between phases leaves it in-doubt indefinitely: locks held, connection pool filling with waiters, and in Postgres a pinned transaction ID that stops vacuum cluster-wide and ends in wraparound protection. Recovery needs a human, who may commit one participant and abort another — violating the very property 2PC provides.
Even when nothing crashes, 2PC costs two round trips plus fsyncs, and its availability is the product of all participants' — five services at 99.9% gives 99.5%. It also cannot include an HTTP API, and under partition it blocks outright. It is right inside one database, for Kafka-to-Kafka transactions, and in a single-vendor cluster with a replicated coordinator and short transactions.
A saga trades atomicity for progress: local transactions that each commit, with compensations for those already done. Orchestrate rather than choreograph beyond two or three steps, because "which sagas are stuck and where?" must have a cheap answer. The saga's state must be durable, every step idempotent, and every step retried with backoff before being treated as failed.
Sagas also abandon isolation, and that is where the bugs are. Intermediate states are visible system-wide, reintroducing dirty reads and lost updates at the architecture level. Counter with a semantic lock (an explicit pending state — whose cost is that every consumer must now handle it, or the isolation failure shows up as a product bug), commutative updates, re-validation at the pivotal step, reordering, or version checks.
Compensation is a forward transaction that approximates undo, not a rollback: a refund is not an un-charge (fees, FX, and both entries on the statement), releasing stock may find it already sold, and cancelling a shipment that has left is a return with a cost.
Order the saga into compensatable steps, a pivot, then retriable steps. Non-compensable effects — email sent, SMS delivered, webhook fired, parcel dispatched — have no compensation and must therefore come after the point of no return, where they are retried until they succeed. Doing the cheap non-compensable thing first, like emailing before capturing payment, makes the saga uncompensatable from step one.
Compensation itself fails, transiently (retry) or permanently (closed card, sold stock, shipment on a van, points already spent). The resulting state can go neither forward nor backward, and no algorithm resolves it.
The real safety net is a human-in-the-loop dead-letter queue with first-class tooling: a queryable list of stuck sagas alerting on count and oldest age, the full step-by-step record with errors and context, idempotent operator actions (retry, force-compensate, skip with a reason, resolve), a path to write a reversing ledger entry, and reconciliation against downstream records. Without it a saga implementation appears to work while accumulating stuck cases that are discovered weeks later by a customer, and remediated by hand-written UPDATEs in production.
Durable execution engines (Temporal, Restate, Step Functions) persist the orchestrator's control flow so a crash replays completed steps rather than re-running them, removing the hand-rolled state machine and most of the retry bookkeeping — at the cost of another stateful system and a constrained programming model. They do not remove the pivot ordering or the operator queue.
The outbox is not a distributed transaction; it removes the need for one in the common "update my state and tell the world" case. One local transaction writes both the state change and the event, a relay publishes — best via log-based CDC over the outbox table (Module P-23) — and an inbox with UNIQUE (event_id) deduplicates on the consumer, giving exactly-once effect over at-least-once delivery. Partition on the aggregate ID for per-entity ordering, and partition the table by day so it does not bloat.
The next module, Microservices vs Monolith, asks the question that determines how much of this you need at all. Every saga, every outbox, every fenced lock and every eventual-consistency countermeasure in this phase is a cost imposed by a service boundary — so the boundary had better be somewhere that earns it. A-4 is about where those boundaries belong, what a distributed monolith looks like when you guess wrong, and the honest list of reasons not to split.
Knowledge Check
A team implements order fulfilment as a saga in this order: (1) send the customer an order-confirmation email, (2) reserve inventory, (3) authorise the card, (4) capture the payment, (5) create the shipment. When step 3 fails on a declined card, they find they cannot compensate step 1. What structural rule does the module give, and where should the pivot be?
An architect proposes using XA two-phase commit across the order service's Postgres, the inventory service's Postgres, and the payment provider's HTTP API, arguing it is the only way to get true atomicity. Which objection does the module treat as decisive, and what is the recommended alternative for the common case?
A saga implementation has been in production for four months. The happy path works, transient failures retry successfully, and the compensation path has unit tests. Finance then reports twelve customers over that period who were charged with no shipment created and no refund issued. What does the module identify as the missing piece?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
charge card ✓
decrement stock ✓
create shipment ✗ ← warehouse service returns 500
award points —
send receipt —
The customer has paid. Stock is reserved. Nothing will ship.
And your process just returned an error to a user whose card was charged.
PHASE 1 — PREPARE
coordinator → all participants: "can you commit? promise it."
each participant: durably writes its changes, takes locks, fsyncs,
replies YES — and is now FORBIDDEN to abort unilaterally.
PHASE 2 — COMMIT (only if every participant said YES)
coordinator → all: "commit." (or "abort" if any said NO)
┌─── compensatable ───┐ ┌─ PIVOT ─┐ ┌──── retriable ────┐
T1 T2 T3 T4 T5 T6
reserve authorise validate CAPTURE ship email
↑ each has a working C ↑ point of ↑ no compensation exists;
no return must eventually succeed
// The value is that a process crash between these lines is recoverable —// on restart, the engine replays completed steps from history rather than re-running them.const auth =await step.run("authorise",()=> psp.authorise(order));const stock =await step.run("reserve",()=> inventory.reserve(order));await step.run("capture",()=> psp.capture(auth.id));// the pivotawait step.run("ship",()=> shipping.create(order));// retriable, no compensation
await db.query("UPDATE orders SET status='shipped' WHERE id=$1",[id]);await kafka.send({ topic:"orders", messages:[{ value: event }]});// crash between → the world never learns; broker down → same; DB rollback after → phantom event
BEGIN;UPDATE orders SETstatus='shipped'WHERE id =42;INSERTINTO outbox (aggregate, aggregate_id,type, payload, created_at)VALUES('order',42,'OrderShipped', $1,now());COMMIT;-- both, or neither. No coordinator, no in-doubt state.