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.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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:

CostDetail
Latencytwo round trips plus a durable fsync per participant, per transaction
Availabilitythe product of all participants' availability — five services at 99.9% gives 99.5%, and any one being down blocks every commit
Couplingall participants must be up, reachable, and mutually trusting simultaneously
Scopeneeds XA or an equivalent protocol; an HTTP API cannot be a participant, which excludes most of a modern architecture
Partition behaviourit 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.

ChoreographyOrchestration
Where the flow livesimplicit, spread across N servicesone explicit state machine
"What is the current state of order 4417?"assemble it from event logsread one row
Couplingservices couple to each other's eventsservices couple to the coordinator
Adding a stepdeploy a new subscriberchange the coordinator
Cyclic-dependency riskhigh, and easy to create by accidentlow
Testabilityhard — behaviour is emergentstraightforward
Compensation logicscattered, and easily incompletecentralised, 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:

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.