The previous lesson ended with the most uncomfortable question of the module. In the monolith of 01-06, creating an order was one transaction: BEGIN, insert the order, deduct the stock, record the charge, COMMIT; if any step failed, ROLLBACK and it was as though nothing had happened. Today that same operation crosses three services with three databases, km0_orders, km0_inventory and km0_payments, each replicated as we saw in 03-04, and no PostgreSQL transaction spans all three. If Mark's charge is rejected after the last aged-cheese has been deducted, somebody has to put it back in stock; if orders dies between the deduction and the charge, Mark's order is left in a limbo nobody designed.
This lesson presents the two families of answers. The first tries to preserve the monolith's atomicity with a two-phase commit protocol (2PC), which PostgreSQL supports through PREPARE TRANSACTION; we will see how it works, why it blocks when the coordinator crashes and why microservices avoid it. The second gives up atomicity and replaces it with a saga: a sequence of local transactions, each with its compensation, coordinated by events (choreography) or by an orchestrator with persisted state. We will implement the orders orchestrator in Python, with its sagas table in km0_orders, and watch it compensate the stock when payments rejects Mark; then we will write the same saga as a choreography with Kafka consumers and compare the two. We will finish with what sagas lose compared with ACID (isolation) and the countermeasures, and with the TCC pattern. The conclusion closes Module 3 and opens Module 4, devoted to where and how the data we now know how to replicate and coordinate is physically stored.
Contents
- ACID and the transaction that no longer exists
- Two-phase commit (2PC)
- Why 2PC blocks, and 3PC
- Real-world 2PC: XA and
PREPARE TRANSACTIONin PostgreSQL - Why microservices avoid 2PC
- Sagas: local transactions with compensations
- Choreography: the saga as a chain of events
- Orchestration:
order_saga.pywith persisted state - Choreography vs orchestration table
- Compensations, idempotency and the lost isolation; TCC
- Common mistakes and tips
- Exercises
- Conclusion
- ACID and the transaction that no longer exists
Let us recall what the monolith's transaction promised, because each letter of ACID is going to meet a different fate in the distributed system:
| Property | What it guarantees | What happens to it when you distribute |
|---|---|---|
| Atomicity | All the steps or none | It is what 2PC tries to preserve and what sagas replace with compensations |
| Consistency (the ACID one) | The schema's invariants (keys, constraints) are respected | It remains local to each database; invariants across services ("no paid order without reserved stock") become the application's responsibility |
| Isolation | Concurrent transactions do not see each other half done | It is lost across services: others see the saga's intermediate steps (section 10) |
| Durability | What has been committed survives failures | Each database provides it separately (with the replication of 03-04) |
The original transaction, in km0/sql/monolith.sql from 01-06, was essentially this:
BEGIN;
INSERT INTO orders (id, customer, status, total) VALUES ('P-2026-000124', 'Mark', 'confirmed', 24.90);
UPDATE stock SET units = units - 1 WHERE product = 'aged-cheese' AND units >= 1;
INSERT INTO charges (order_id, amount, status) VALUES ('P-2026-000124', 24.90, 'charged');
COMMIT;If the UPDATE affected no rows (out of stock) or the payment gateway failed, a ROLLBACK undid everything. Today the three statements live in three services, and the question is what replaces the COMMIT.
- Two-phase commit (2PC)
Two-phase commit (Gray, 1978) is the classic atomic commit protocol: getting several participants, each with its own local transaction, to all commit or all abort. Its roles:
- Coordinator: the process that drives the protocol (in our case it would be
orders, or an external transaction manager). - Participants: the databases or services with an open local transaction (
km0_orders,km0_inventory,km0_payments).
sequenceDiagram
participant C as Coordinator (orders)
participant O as km0_orders
participant I as km0_inventory
participant P as km0_payments
Note over C,P: Phase 0: work (local transactions open)
C->>O: INSERT order
C->>I: UPDATE stock
C->>P: INSERT charge
Note over C,P: Phase 1: PREPARE (voting)
C->>O: prepare
C->>I: prepare
C->>P: prepare
O-->>C: yes (written to disk, locks held)
I-->>C: yes
P-->>C: yes
Note over C: Decision: COMMIT, written to the coordinator's log
Note over C,P: Phase 2: COMMIT
C->>O: commit
C->>I: commit
C->>P: commit
O-->>C: done
I-->>C: done
P-->>C: done
Phase 1, prepare. The coordinator asks each participant "can you commit?". A participant that answers yes commits itself irrevocably: it writes the transaction to disk in such a way that it can commit it even after a restart, and it holds its locks until the decision arrives. A participant may answer no (constraint violation, out of stock, card declined), and then the decision will be to abort.
Phase 2, commit or abort. If everyone has said yes, the coordinator writes the decision to its own log (this is the point of no return: from here on the transaction is committed even though nobody knows it yet) and sends commit to everyone; if anyone said no, it sends abort. The participants carry out the decision and reply; the coordinator can retry phase 2 as many times as necessary, because a participant in the "prepared" state can always obey.
The protocol's correctness rests on two promises: the prepared participant never decides on its own, and the coordinator never forgets a decision it has made. Both require disk writes (fsync) at the right moment, which is why 2PC costs at least two network round trips and two fsync calls per participant, on top of the work itself.
- Why 2PC blocks, and 3PC
The weak point lies in the word "never" in the first promise. Imagine that all three participants have answered yes and the coordinator dies immediately afterwards, before sending the decision (or even before writing it). The participants are in the prepared state: holding their locks (the stock row for aged-cheese locked, Mark's order invisible to reads that need the lock) and unable to decide anything:
- They cannot abort, because the coordinator may have decided to commit and already told another participant.
- They cannot commit, because another participant may have said no.
- Asking the other participants does not always help: if they are all prepared, none of them knows the decision.
All they can do is wait for the coordinator to come back and read its log. If the coordinator takes an hour, that stock row is locked for an hour; this is what is known as the blocking problem of 2PC, and it is an availability problem in the sense of 03-02: the system sacrifices A (live participants that cannot make progress) to preserve atomicity. In practice, operators end up resolving prepared transactions by hand (deciding commit or abort by inspection), which is called a heuristic decision, with the obvious risk of deciding differently from the coordinator.
Three-phase commit (3PC, Skeen, 1981) adds an intermediate phase (pre-commit) so that the participants can work out the decision without the coordinator, and eliminates blocking... at the cost of assuming a synchronous network with bounded delays and no partitions, precisely what 01-02 and 03-02 told us we do not have. With a partition, 3PC can lead one side to commit and the other to abort. That is why hardly anybody uses it, and why the modern solution to 2PC blocking is to make the coordinator fault-tolerant through consensus (03-03): Spanner, for example, runs 2PC between Paxos groups, so that the "coordinator" is a replicated group that does not die. It is correct and it is very expensive.
- Real-world 2PC: XA and
PREPARE TRANSACTION in PostgreSQL
PREPARE TRANSACTION in PostgreSQLThe XA standard (X/Open, 1990s) defines the interface between a transaction manager and the participating resources (databases, queues); Java exposes it as JTA, and classic application servers used it for transactions that spanned a database and a JMS queue. PostgreSQL implements the participant side with three statements: PREPARE TRANSACTION 'id' (phase 1: the current transaction becomes prepared and survives restarts), COMMIT PREPARED 'id' and ROLLBACK PREPARED 'id' (phase 2). It requires max_prepared_transactions > 0 in postgresql.conf (the default is 0, deliberately). A minimal coordinator in Python between km0_inventory and km0_orders:
# km0/simulations/two_phase_pg.py
import psycopg # pip install "psycopg[binary]"
ORDERS_DSN = "host=localhost port=5432 dbname=km0_orders user=km0 password=km0"
INVENTORY_DSN = "host=localhost port=5434 dbname=km0_inventory user=km0 password=km0"
TX_ID = "order-P-2026-000124" # global transaction identifier
def two_phase(order_id: str, customer: str, product: str, total: float) -> None:
ords = psycopg.connect(ORDERS_DSN, autocommit=True)
inv = psycopg.connect(INVENTORY_DSN, autocommit=True)
participants = [ords, inv]
try:
# Phase 0: work in open local transactions
ords.execute("BEGIN")
ords.execute("INSERT INTO orders (id, customer, status, total) VALUES (%s, %s, 'confirmed', %s)",
(order_id, customer, total))
inv.execute("BEGIN")
affected = inv.execute("UPDATE stock SET units = units - 1 WHERE product = %s AND units >= 1",
(product,)).rowcount
if affected == 0:
raise RuntimeError(f"no stock of {product}")
# Phase 1: prepare (each participant votes; if it fails, it raises an exception = a "no" vote)
for conn in participants:
conn.execute(f"PREPARE TRANSACTION '{TX_ID}'")
print("phase 1: all prepared; the decision is COMMIT")
# >>> If the coordinator dies HERE, both databases are left prepared and locked <<<
# Phase 2: commit
for conn in participants:
conn.execute(f"COMMIT PREPARED '{TX_ID}'")
print("phase 2: committed on both")
except Exception as e:
print("aborting:", e)
for conn in participants:
try:
conn.execute(f"ROLLBACK PREPARED '{TX_ID}'") # if it got as far as preparing
except psycopg.Error:
conn.execute("ROLLBACK") # if it did not
finally:
ords.close(); inv.close()
if __name__ == "__main__":
two_phase("P-2026-000124", "Mark", "aged-cheese", 24.90)To see the blocking with your own eyes, kill the process (or add a sys.exit()) right after phase 1 and run this query on either database:
gid | prepared | owner | database ---------------------+-------------------------------+-------+--------------- order-P-2026-000124 | 2026-09-14 10:41:07.113+02 | km0 | km0_inventory
The prepared transaction survives even a server restart, and for as long as it exists, the aged-cheese row is locked: any other UPDATE on it waits indefinitely. Only an explicit COMMIT PREPARED or ROLLBACK PREPARED releases it, and deciding which is the heuristic resolution of section 3. (The equivalent of PREPARE TRANSACTION exists in MySQL as XA PREPARE, and in queues such as ActiveMQ or IBM MQ; Kafka does not take part in XA, and neither does RabbitMQ, which already greatly limits its use with our architecture.)
- Why microservices avoid 2PC
With 2PC working in two lines of SQL, the natural question is why not use it for Mark's order. The reasons are all practical:
- Coupling: the coordinator needs direct transactional access to the databases of
inventoryandpayments, which breaks the rule "each service owns its data" from 01-06. The alternative, having each service exposeprepare/commit/abortoperations over gRPC, is possible but turns every service into an XA resource manager, with its logs, its recovery and its orphaned transactions. - Availability: the blocking of section 3 means that a crash of
orders(the coordinator) leaves rows locked ininventoryandpayments. A failure in one service becomes a failure in three: the opposite of the fault isolation we were after when we split the services (01-03). - Latency and throughput: two phases with an
fsyncon every participant, with locks held throughout the exchange, limit the number of orders per second to whatever the slowest participant can bear. - Heterogeneity:
paymentstalks to an external gateway (02-05) that takes part in no 2PC: you cannot "prepare" a card charge. Kafka and Redis are not XA participants either. As soon as one step is not a relational database, 2PC no longer covers the whole operation. - Operations: orphaned prepared transactions require manual intervention, and teams that have run XA in production tend to describe it as the most frequent source of night-time incidents.
2PC is still the right tool inside a system that controls everything (a distributed database such as Spanner or CockroachDB uses it between its own nodes, with a coordinator replicated through consensus), but between independent services the industry has converged on the alternative that gives up atomicity: the saga.
- Sagas: local transactions with compensations
The saga pattern (García-Molina and Salem, 1987, for long-lived transactions in a single database) was reformulated for microservices a decade ago. A saga is a sequence of local transactions T1, T2, ..., Tn, each in one service and committed separately, such that:
- If they all succeed, the business operation is complete.
- If Ti fails, the compensating transactions C(i-1), ..., C1 of those already committed are executed, in reverse order, leaving the system in a state that is semantically equivalent to the initial one (not identical: the cancelled order still exists as a record, the refunded charge shows up on Mark's statement).
For the Kilometre Zero order:
| Step | Local transaction | Service | Compensation |
|---|---|---|---|
| T1 | Create the order in pending status |
orders |
C1: mark the order cancelled |
| T2 | Reserve stock (ReserveStock from 02-03, with reservation_id) |
inventory |
C2: release the reservation |
| T3 | Charge (with the Idempotency-Key from 02-05) |
payments |
C3: refund |
| T4 | Mark the order confirmed |
orders |
(none: it is the last step) |
Steps fall into three types, and the order in which they are placed matters:
- Compensatable: they have a compensation (T1, T2). They go first.
- Pivot: the step that decides whether the saga will succeed; once it has committed, the saga cannot abort (T3, the charge: if the charge goes through, the order ships). It is usually the step most likely to fail or the one that cannot be cleanly compensated, and it is placed as late as possible.
- Retriable: after the pivot, steps that cannot fail permanently, only transiently, and are retried until they work (T4).
sequenceDiagram
participant Ord as orders
participant Inv as inventory
participant Pay as payments
Note over Ord,Pay: Happy-path saga: Anna's order
Ord->>Ord: T1 create P-2026-000123 (pending)
Ord->>Inv: T2 ReserveStock(aged-cheese, reservation_id)
Inv-->>Ord: reserved
Ord->>Pay: T3 charge(€24.90, Idempotency-Key)
Pay-->>Ord: charged
Ord->>Ord: T4 mark confirmed
sequenceDiagram
participant Ord as orders
participant Inv as inventory
participant Pay as payments
Note over Ord,Pay: Saga with compensation: Mark's order
Ord->>Ord: T1 create P-2026-000124 (pending)
Ord->>Inv: T2 ReserveStock(aged-cheese, reservation_id)
Inv-->>Ord: reserved
Ord->>Pay: T3 charge(€24.90)
Pay-->>Ord: REJECTED (card)
Ord->>Inv: C2 ReleaseReservation(reservation_id)
Inv-->>Ord: released
Ord->>Ord: C1 mark cancelled (reason: payment rejected)
There are two ways of coordinating who executes each step and each compensation, and they are the subject of the next two sections.
- Choreography: the saga as a chain of events
In a choreographed saga there is no coordinator: each service reacts to the others' events and publishes its own, over the orders.events topic of 02-04 and 02-05 (or per-service topics). The order's chain of events:
flowchart LR
A[orders:<br/>order.created] --> B[inventory:<br/>stock.reserved]
A --> B2[inventory:<br/>stock.insufficient]
B --> C[payments:<br/>payment.confirmed]
B --> C2[payments:<br/>payment.rejected]
C --> D[orders:<br/>order.confirmed]
C2 --> E[inventory:<br/>stock.released]
B2 --> F[orders:<br/>order.cancelled]
E --> F
Each service implements its part as an idempotent consumer from 02-05 which, in the same transaction, applies the local effect and writes the next event to its outbox. The inventory fragment, which reacts to order.created and to payment.rejected:
# km0/services/inventory/saga_consumer.py
import json
import uuid
import psycopg
from confluent_kafka import Consumer
DSN = "host=inventory-db dbname=km0_inventory user=km0 password=km0"
consumer = Consumer({"bootstrap.servers": "kafka:9092", "group.id": "inventory-saga",
"enable.auto.commit": False, "auto.offset.reset": "earliest"})
consumer.subscribe(["orders.events", "payments.events"])
def publish(cur, type: str, order_id: str, data: dict) -> None:
"""Writes to the outbox (02-05); the relay will publish it to inventory.events."""
cur.execute("INSERT INTO outbox (id, aggregate, aggregate_id, type, payload) VALUES (%s, 'order', %s, %s, %s)",
(uuid.uuid4(), order_id, type, json.dumps({"order_id": order_id, **data})))
def process(conn: psycopg.Connection, event: dict) -> None:
order_id = event["data"]["order_id"]
with conn.transaction():
cur = conn.cursor()
cur.execute("INSERT INTO processed_messages (message_id, consumer) VALUES (%s, 'inventory.saga') "
"ON CONFLICT DO NOTHING", (event["event_id"],))
if cur.rowcount == 0:
return # duplicate: already processed
if event["type"] == "order.created": # T2: reserve
ok = True
for line in event["data"]["lines"]:
cur.execute("UPDATE stock SET units = units - %s WHERE product = %s AND units >= %s",
(line["quantity"], line["product"], line["quantity"]))
ok = ok and cur.rowcount == 1
if not ok:
raise psycopg.Rollback # undoes the partial UPDATEs...
cur.execute("INSERT INTO reservations (order_id, lines) VALUES (%s, %s)",
(order_id, json.dumps(event["data"]["lines"])))
publish(cur, "stock.reserved", order_id, {"lines": event["data"]["lines"]})
elif event["type"] == "payment.rejected": # C2: release
cur.execute("DELETE FROM reservations WHERE order_id = %s RETURNING lines", (order_id,))
row = cur.fetchone()
if row: # if there is no reservation, it was already released
for line in json.loads(row[0]) if isinstance(row[0], str) else row[0]:
cur.execute("UPDATE stock SET units = units + %s WHERE product = %s",
(line["quantity"], line["product"]))
publish(cur, "stock.released", order_id, {"reason": "payment rejected"})
def process_out_of_stock(conn: psycopg.Connection, event: dict) -> None:
"""Failure branch of T2: published in a separate transaction, after the rollback."""
with conn.transaction():
cur = conn.cursor()
cur.execute("INSERT INTO processed_messages (message_id, consumer) VALUES (%s, 'inventory.saga') "
"ON CONFLICT DO NOTHING", (event["event_id"],))
publish(cur, "stock.insufficient", event["data"]["order_id"], {})
with psycopg.connect(DSN) as conn:
while True:
msg = consumer.poll(1.0)
if msg is None or msg.error():
continue
event = json.loads(msg.value())
try:
process(conn, event)
except psycopg.Rollback:
process_out_of_stock(conn, event)
consumer.commit(message=msg)(psycopg.Rollback is the exception that conn.transaction() catches in order to undo the block without propagating it; here we reuse it as an "out of stock" signal. In the same style, payments consumes stock.reserved, charges with the Idempotency-Key from 02-05 and publishes payment.confirmed or payment.rejected; and orders consumes payment.confirmed for T4 and stock.insufficient/stock.released for C1.)
Choreography is attractive for its apparent simplicity: there is no new component, only consumers. Its problems appear as it grows: to find out what state Mark's order is in you have to reconstruct it from events scattered across three topics; adding a step (for example, "assign courier" between the charge and the confirmation) means touching several services; and cyclic dependencies (orders listens to payments, which listens to inventory, which listens to orders) make it hard to reason about the flow as a whole. With three steps it is manageable; with eight, it is not.
- Orchestration:
order_saga.py with persisted state
order_saga.py with persisted stateIn an orchestrated saga, one component, the orchestrator, knows what the sequence is, invokes each step (through synchronous gRPC or by publishing commands), interprets the response and decides on the next step or the compensation. The orchestrator is a state machine whose state is persisted after every transition, so that if the process dies, another instance (or the same one on restart) picks it up where it left off. At Kilometre Zero it lives in orders, because that is the service that owns the concept of "order" and the one that starts the operation, with this table in km0_orders:
-- km0/sql/orders/sagas.sql
CREATE TABLE sagas (
id UUID PRIMARY KEY,
type TEXT NOT NULL, -- 'order'
order_id TEXT NOT NULL UNIQUE,
state TEXT NOT NULL, -- STARTED, STOCK_RESERVED, PAYMENT_CONFIRMED, COMPLETED,
-- COMPENSATING, CANCELLED
current_step INTEGER NOT NULL DEFAULT 0, -- last step completed successfully
data JSONB NOT NULL, -- what the steps and compensations need
history JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sagas_in_progress ON sagas (updated_at) WHERE state NOT IN ('COMPLETED', 'CANCELLED');The orchestrator defines the steps as (action, compensation) pairs, moves forward persisting as it goes, and compensates backwards if a step fails permanently. So that the example can be run with no infrastructure, the inventory and payments clients and the saga repository have simulated versions; the real ones would be the gRPC stub from 02-03, the gateway client from 02-05 and psycopg on the table above.
# km0/services/orders/order_saga.py
import json
import uuid
from dataclasses import dataclass, field
from typing import Callable, Protocol
class PermanentFailure(Exception):
"""The step will not succeed even if retried: we have to compensate."""
class TransientFailure(Exception):
"""The step may succeed if retried (timeout, 503, gRPC deadline)."""
# --- Persisted state ---------------------------------------------------------
@dataclass
class Saga:
id: str
order_id: str
state: str = "STARTED"
current_step: int = 0
data: dict = field(default_factory=dict)
history: list = field(default_factory=list)
class Repository(Protocol):
def save(self, saga: Saga) -> None: ...
def load(self, saga_id: str) -> Saga: ...
class InMemoryRepository:
"""For the simulation. The real version runs UPDATE sagas SET ... WHERE id = %s with psycopg."""
def __init__(self) -> None:
self.rows: dict[str, str] = {}
def save(self, saga: Saga) -> None:
self.rows[saga.id] = json.dumps(saga.__dict__) # as JSONB would
def load(self, saga_id: str) -> Saga:
return Saga(**json.loads(self.rows[saga_id]))
# --- Steps -------------------------------------------------------------------
@dataclass
class Step:
name: str
state_on_success: str
action: Callable[[Saga], None]
compensation: Callable[[Saga], None] | None # None = pivot step or later
class OrderSagaOrchestrator:
def __init__(self, repo: Repository, inventory, payments, orders_db, max_retries: int = 3):
self.repo, self.max_retries = repo, max_retries
self.steps = [
Step("reserve_stock", "STOCK_RESERVED",
action=lambda s: inventory.reserve(s.data["reservation_id"], s.order_id, s.data["lines"]),
compensation=lambda s: inventory.release(s.data["reservation_id"])),
Step("charge", "PAYMENT_CONFIRMED",
action=lambda s: payments.charge(s.data["idempotency_key"], s.data["customer"], s.data["total"]),
compensation=lambda s: payments.refund(s.data["idempotency_key"])),
Step("confirm_order", "COMPLETED",
action=lambda s: orders_db.change_status(s.order_id, "confirmed"),
compensation=None),
]
self.orders_db = orders_db
def _transition(self, saga: Saga, state: str, note: str) -> None:
saga.state = state
saga.history.append(f"{state}: {note}")
self.repo.save(saga) # persist BEFORE carrying on
print(f" [saga {saga.order_id}] -> {state} ({note})")
def start(self, order_id: str, customer: str, lines: list[dict], total: float) -> Saga:
saga = Saga(id=str(uuid.uuid4()), order_id=order_id,
data={"customer": customer, "lines": lines, "total": total,
"reservation_id": f"res-{order_id}", "idempotency_key": f"charge-{order_id}"})
self.orders_db.create(order_id, customer, total) # T1, in the SAME transaction as the saga
self._transition(saga, "STARTED", "order created in pending status")
return self.resume(saga.id)
def resume(self, saga_id: str) -> Saga:
"""Moves forward (or compensates) from the persisted state. Re-entrant: it can be called after a restart."""
saga = self.repo.load(saga_id)
if saga.state == "COMPENSATING":
return self._compensate(saga)
while saga.current_step < len(self.steps):
step = self.steps[saga.current_step]
for attempt in range(1, self.max_retries + 1):
try:
step.action(saga)
break
except TransientFailure as e:
print(f" [saga {saga.order_id}] {step.name}: transient failure ({e}), attempt {attempt}")
if attempt == self.max_retries and step.compensation is None:
return saga # retriable step: leave it for a periodic process
except PermanentFailure as e:
self._transition(saga, "COMPENSATING", f"{step.name} failed: {e}")
return self._compensate(saga)
else:
self._transition(saga, "COMPENSATING", f"{step.name} ran out of retries")
return self._compensate(saga)
saga.current_step += 1
self._transition(saga, step.state_on_success, f"{step.name} ok")
return saga
def _compensate(self, saga: Saga) -> Saga:
while saga.current_step > 0:
step = self.steps[saga.current_step - 1]
if step.compensation is not None:
step.compensation(saga) # must be idempotent
print(f" [saga {saga.order_id}] compensated {step.name}")
saga.current_step -= 1
self.repo.save(saga)
self.orders_db.change_status(saga.order_id, "cancelled") # C1
self._transition(saga, "CANCELLED", "all compensations applied")
return saga
# --- Simulated services --------------------------------------------------------
class SimulatedInventory:
def __init__(self) -> None:
self.stock = {"aged-cheese": 1, "crianza-wine": 12}
self.reservations: dict[str, list[dict]] = {}
def reserve(self, reservation_id: str, order_id: str, lines: list[dict]) -> None:
if reservation_id in self.reservations:
return # idempotent (02-03)
for l in lines:
if self.stock[l["product"]] < l["quantity"]:
raise PermanentFailure(f"no stock of {l['product']}")
for l in lines:
self.stock[l["product"]] -= l["quantity"]
self.reservations[reservation_id] = lines
print(f" inventory: reserved {lines} -> stock {self.stock}")
def release(self, reservation_id: str) -> None:
lines = self.reservations.pop(reservation_id, None) # idempotent: if it does not exist, nothing
if lines:
for l in lines:
self.stock[l["product"]] += l["quantity"]
print(f" inventory: released {reservation_id} -> stock {self.stock}")
class SimulatedPayments:
def __init__(self) -> None:
self.charges: dict[str, float] = {}
self.declined_cards = {"Mark"}
def charge(self, key: str, customer: str, amount: float) -> None:
if key in self.charges:
return # idempotent (02-05)
if customer in self.declined_cards:
raise PermanentFailure("card declined by the issuer")
self.charges[key] = amount
print(f" payments: charged {amount:.2f} EUR to {customer}")
def refund(self, key: str) -> None:
if key in self.charges:
print(f" payments: refunded {self.charges.pop(key):.2f} EUR")
class SimulatedOrdersDb:
def __init__(self) -> None:
self.orders: dict[str, dict] = {}
def create(self, order_id: str, customer: str, total: float) -> None:
self.orders[order_id] = {"customer": customer, "total": total, "status": "pending"}
def change_status(self, order_id: str, status: str) -> None:
self.orders[order_id]["status"] = status
if __name__ == "__main__":
inventory, payments, orders_db = SimulatedInventory(), SimulatedPayments(), SimulatedOrdersDb()
orch = OrderSagaOrchestrator(InMemoryRepository(), inventory, payments, orders_db)
print("Saga 1: Anna buys the last aged cheese")
orch.start("P-2026-000123", "Anna", [{"product": "aged-cheese", "quantity": 1}], 24.90)
print("\nSaga 2: Mark tries to buy aged cheese (there is none left)")
orch.start("P-2026-000124", "Mark", [{"product": "aged-cheese", "quantity": 1}], 24.90)
print("\nSaga 3: Mark buys crianza wine, but his card is declined")
orch.start("P-2026-000125", "Mark", [{"product": "crianza-wine", "quantity": 2}], 31.80)
print("\nFinal status of the orders:")
for oid, o in orders_db.orders.items():
print(f" {oid}: {o['status']}")
print("final stock:", inventory.stock, "| charges:", payments.charges)Output:
Saga 1: Anna buys the last aged cheese
[saga P-2026-000123] -> STARTED (order created in pending status)
inventory: reserved [{'product': 'aged-cheese', 'quantity': 1}] -> stock {'aged-cheese': 0, 'crianza-wine': 12}
[saga P-2026-000123] -> STOCK_RESERVED (reserve_stock ok)
payments: charged 24.90 EUR to Anna
[saga P-2026-000123] -> PAYMENT_CONFIRMED (charge ok)
[saga P-2026-000123] -> COMPLETED (confirm_order ok)
Saga 2: Mark tries to buy aged cheese (there is none left)
[saga P-2026-000124] -> STARTED (order created in pending status)
[saga P-2026-000124] -> COMPENSATING (reserve_stock failed: no stock of aged-cheese)
[saga P-2026-000124] -> CANCELLED (all compensations applied)
Saga 3: Mark buys crianza wine, but his card is declined
[saga P-2026-000125] -> STARTED (order created in pending status)
inventory: reserved [{'product': 'crianza-wine', 'quantity': 2}] -> stock {'aged-cheese': 0, 'crianza-wine': 10}
[saga P-2026-000125] -> STOCK_RESERVED (reserve_stock ok)
[saga P-2026-000125] -> COMPENSATING (charge failed: card declined by the issuer)
inventory: released res-P-2026-000125 -> stock {'aged-cheese': 0, 'crianza-wine': 12}
[saga P-2026-000125] compensated reserve_stock
[saga P-2026-000125] -> CANCELLED (all compensations applied)
Final status of the orders:
P-2026-000123: confirmed
P-2026-000124: cancelled
P-2026-000125: cancelled
final stock: {'aged-cheese': 0, 'crianza-wine': 12} | charges: {'charge-P-2026-000123': 24.9}The important points of the code, for anyone reading it for the first time:
- Persist before carrying on.
_transitionsaves the saga on every state change, andcurrent_stepis incremented only after the step has succeeded. If the process dies betweenstep.action(saga)and_transition, on restartresume(saga_id)will run the same step again, which is why every action must be idempotent:reservewith the samereservation_iddoes not deduct twice (02-03),chargewith the sameidempotency_keydoes not charge twice (02-05). The saga directly inherits the work of Module 2. - The identifiers are decided at the start.
reservation_idandidempotency_keyare derived from theorder_idand stored indataon the first transition, so that a retry after a restart uses exactly the same ones. - Permanent versus transient failure. A
PermanentFailure(out of stock, card declined) triggers compensation; aTransientFailure(gRPC deadline, 503 from the gateway) is retried, and if it happens in a step after the pivot it is left for a periodic process that callsresumeon the sagas in progress (thesagas_in_progressindex exists for that). - Compensations are idempotent too (
releaseon a non-existent reservation does nothing) and are executed in reverse order, without touching the steps that never got to run (in saga 2, there is nothing to release). - T1 and the saga in the same transaction. In the real version,
orders_db.createand the firstsaveof the saga go in a singlekm0_orderstransaction (and, if the saga is driven by events, the first command goes to theoutboxin that same transaction). That way there can be no pending order without a saga and no saga without an order.
In the three simulated sagas, the outcome is what the monolith would have produced with ROLLBACK: Anna has her cheese and her charge; Mark has neither cheese nor wine, he has not been charged anything, and the wine stock is back at 12. The difference is that it happened in separate steps, visible from outside, and that the cancelled orders exist as records.
- Choreography vs orchestration table
| Aspect | Choreography | Orchestration |
|---|---|---|
| Who knows the sequence | Nobody in particular: it is spread across the consumers | The orchestrator |
| New components | None (consumers + outbox) | The orchestrator and its state table |
| Coupling | Low between services, but each one knows the others' events | The services only know their commands; the orchestrator knows them all |
| Finding out what state Mark's order is in | Reconstruct it from events on several topics | SELECT state FROM sagas WHERE order_id = ... |
| Adding a step | Touch several services | Touch the orchestrator (and the new service) |
| Cyclic dependencies | Easy to create by accident | Impossible: the orchestrator is the only caller |
| Risk of a "god" component | No | The orchestrator may accumulate business logic that does not belong to it |
| Testing | Integration with a broker | The orchestrator is tested in isolation with test doubles (as in the simulation) |
| Usual recommendation | Sagas of 2-3 steps, stable flows | Sagas of 4+ steps, flows that change, a need to query the state |
| Tools | Kafka/RabbitMQ + outbox | Temporal, Camunda/Zeebe, AWS Step Functions, or a home-grown orchestrator like the one in section 8 |
At Kilometre Zero, the order saga has three steps today but it will grow (courier assignment, notifying the producer, campaign coupons), and the support team needs to know what state each order is in; that is why the choice is orchestration in orders. Choreography is reserved for simple, stable reactions, such as analytics consuming order.confirmed.
- Compensations, idempotency and the lost isolation; TCC
Compensating is not undoing
A compensation is a new business transaction with effects of its own, not a ROLLBACK: the refund shows up on Mark's statement, the released reservation generates a stock.updated that catalog will display, the cancelled order stays in the history with its reason. Some actions have no possible compensation (an email that has been sent, a courier who has already set off) and must be placed after the pivot. And every compensation must be idempotent and cannot fail permanently: if refund returns a definitive error, the saga is left in a state that demands human intervention, and you have to design for that (a COMPENSATION_FAILED state with an alert, 07-01).
The lost isolation
Sagas give up the I of ACID, and that produces specific anomalies between T1 and T4:
| Anomaly | Example in the order | Countermeasure |
|---|---|---|
| Lost updates | While Mark's saga is in STOCK_RESERVED, another saga cancels the order and releases; then the first one confirms it |
Semantic lock: mark the order as pending (or in_saga) and reject other operations on it until the saga finishes; this is what T1 does |
| Dirty reads | catalog shows the stock deducted by T2 before T3 decides, and then it goes back up |
"Pending confirmation" states: distinguish reserved stock from sold stock; the catalogue shows available = units - reserved and knows it is provisional |
| Non-repeatable / fuzzy reads | analytics adds up the day's sales and counts Mark's order before it is cancelled |
Pessimistic view: only count orders in confirmed status; or reorder the saga so that the most volatile step goes first (reordering) |
| Decisions based on intermediate data | An "Artisan Cheese Week" coupon is applied to an order that is later cancelled, and the coupon counter is not restored | Commutative updates: use operations that can be compensated exactly (increment/decrement, like the PNCounter of 03-01) instead of assignments |
These countermeasures (semantic lock, pessimistic view, reordering, commutative updates, and the "version file" for keeping the previous state) were catalogued by Chris Richardson on the basis of García-Molina's original paper, and most of them boil down to one idea: make it visible that the state is provisional instead of pretending there is isolation.
TCC: Try-Confirm/Cancel
A variant of the saga that recovers some isolation is TCC (Try-Confirm/Cancel): each step is split into try (reserve the resource provisionally, without consuming it), confirm (consume it for good) and cancel (release the reservation). The orchestrator runs all the try operations, and only if they all succeed does it run the confirm operations; otherwise, the cancel operations. It is 2PC at the business level, with no database locks: inventory with ReserveStock and ConfirmReservation/ReleaseReservation is in fact already a TCC resource, and so is the payment gateway with pre-authorisation and capture. The price is that every service has to implement all three operations and manage reservations that expire (a try with neither confirm nor cancel because the orchestrator died), which makes TCC the choice when resources are scarce and contended (the last unit, a place, a seat) and the plain saga the choice for everything else.
Common Mistakes and Tips
- Using 2PC between microservices "because that is what the database does". It couples, it blocks when the coordinator crashes and it does not cover gateways or brokers. Keep 2PC for the inside of a system that controls everything.
- Sagas with non-idempotent steps. After a restart, the orchestrator re-executes the last step. Without a
reservation_id, without an idempotency key, the stock is deducted twice and the customer pays twice. Idempotency identifiers are generated when the saga starts and persisted with it. - Compensations that can fail with no plan. Design the "compensation failed" state, with an alert and a manual procedure, before it happens in production.
- The pivot in the wrong place. If the step that fails most often (the charge) goes first, little gets compensated; if it goes last, everything before it gets compensated. Order them: compensatable steps, then the pivot, then the retriable ones.
- Pretending there is isolation. Showing the stock deducted by a saga in progress as final, or counting pending orders as sales, produces the errors in the table in section 10. Model provisional states explicitly.
- An orchestrator holding other services' business logic. The orchestrator decides the order and the compensations; it does not decide whether there is stock or whether the card is valid. If it starts doing so, it has become a distributed monolith.
- Sagas with no timeout. A saga sitting in
STOCK_RESERVEDfor hours becausepaymentsis not responding holds stock that others would like to buy. Define a maximum time per step and per saga, after which it compensates. - Tip: keep the saga's
history. When a customer asks why their order was cancelled, "charge failed: card declined by the issuer" in thesagasrow is worth more than any log. - Tip: test the orchestrator with test doubles that fail at every step, permanently and transiently, and after a "restart" (calling
resumewith the saved state). It is the kind of test that we will automate with chaos engineering in 07-06.
Exercises
Exercise 1: A restart in the middle of a saga
Using the simulation in section 8, model a crash of the orchestrator right after inventory reserves Lucy's wine (P-2026-000126, 2 units of crianza-wine, valid card) but before the transition to STOCK_RESERVED. Do it with a SimulatedInventory whose reserve raises a SystemExit exception the first time, after having made the reservation. Then create a new OrderSagaOrchestrator with the same InMemoryRepository and the same simulated services, and call resume(saga.id). Which steps are re-executed? Is the stock deducted twice? What would you change in the implementation so that start returned the saga.id even if the process dies halfway through?
Exercise 2: Adding a step to both versions
Kilometre Zero wants to add "assign courier" (delivery.assign(order_id, city), compensation delivery.unassign(order_id), may fail permanently if there are no couriers in the city) between the charge and the confirmation. Describe what has to change in the orchestration (section 8) and in the choreography (section 7): which files, which new events, which consumers. Is it right to place it after the charge? What would a permanent failure in that step mean for Mark, whose card is valid this time?
Exercise 3: Choosing 2PC, saga or TCC
For each operation, choose 2PC, choreographed saga, orchestrated saga or TCC, and justify it in two or three sentences:
- Moving 50 units of
pink-tomatobetween thestocktables ofinv-bcnandinv-vlc(both are PostgreSQL databases of the sameinventoryservice, with no external gateways). - Recording a delivery:
deliverymarks the order delivered,orderschanges the status,analyticsupdates the average delivery time, and an email is sent to Anna. - Reserving the last 3 bottles of
crianza-winefor an order from Lucy and charging them to a card that requires strong customer authentication (the customer may take minutes to confirm it in their banking app).
Solutions
Solution 1:
class DyingInventory(SimulatedInventory):
def __init__(self) -> None:
super().__init__(); self.already_died = False
def reserve(self, reservation_id, order_id, lines):
super().reserve(reservation_id, order_id, lines) # the reservation IS made
if not self.already_died:
self.already_died = True
raise SystemExit("the orders process dies here")
repo, inventory, payments, orders_db = InMemoryRepository(), DyingInventory(), SimulatedPayments(), SimulatedOrdersDb()
try:
OrderSagaOrchestrator(repo, inventory, payments, orders_db).start(
"P-2026-000126", "Lucy", [{"product": "crianza-wine", "quantity": 2}], 31.80)
except SystemExit as e:
print("!!!", e)
saga_id = next(iter(repo.rows)) # in real life: SELECT ... WHERE state NOT IN (...)
orch2 = OrderSagaOrchestrator(repo, inventory, payments, orders_db) # "new instance" after the restart
orch2.resume(saga_id)
print(inventory.stock, orders_db.orders["P-2026-000126"]["status"])The saga was persisted in STARTED with current_step = 0, so resume re-executes reserve_stock. Since SimulatedInventory.reserve is idempotent by reservation_id (res-P-2026-000126 is already in reservations), it does not deduct twice: the stock stays at 10, Lucy is charged and the saga reaches COMPLETED. If you remove the if reservation_id in self.reservations check, you will see the stock at 8: it is exactly the failure that the idempotency of 02-03 prevents. As for start: in the real implementation it returns nothing to anyone if the process dies; what matters is that the saga is in the table, and that a periodic process (SELECT id FROM sagas WHERE state NOT IN ('COMPLETED','CANCELLED') AND updated_at < now() - interval '1 minute') calls resume on orphaned sagas. In addition, start should create the order and the saga in a single transaction and answer the client "order received, pending" before running the steps, which may take a while.
Solution 2:
Orchestration: a single change in order_saga.py: insert a Step("assign_courier", "COURIER_ASSIGNED", action=delivery.assign(...), compensation=delivery.unassign(...)) into the self.steps list between charge and confirm_order, add city to data, and allow the new state in the sagas table. The inventory and payments services do not change. Choreography: delivery needs a new consumer of payment.confirmed that publishes courier.assigned or delivery.impossible; orders stops confirming on receipt of payment.confirmed and does so on courier.assigned instead; payment.rejected stays the same, but delivery.impossible must trigger two compensations: payments must consume it and refund (a new consumer) and then inventory must release on receiving payment.refunded (a new event and a new consumer). Three services touched, two new events, and the compensation chain gets longer.
After the charge? If "assign courier" can fail permanently, placing it after the pivot (the charge) forces a refund, which is the most visible and annoying compensation for the customer (Mark would see a debit and a credit on his statement). It would be better to place it before the charge (reserving a courier is compensatable and cheap to undo), making the charge once again the last compensatable-or-pivot step. General rule: steps that can fail permanently go before the pivot.
Solution 3:
- 2PC (or, better, a single transaction): both tables belong to the same service,
inventory, so there is no undue coupling; if they are in the same logical database nothing distributed is needed, and if they are two PostgreSQL instances,PREPARE TRANSACTIONbetween them, coordinated byinventory, is appropriate: homogeneous participants, no gateways, a short transaction, and the service itself can resolve orphaned prepared transactions on start-up (by queryingpg_prepared_xacts). A saga would be over-engineering. - Choreographed saga:
deliverypublishesorder.deliveredin the same transaction as its status change (outbox), andorders,analyticsand the notification service consume it independently and idempotently. No compensation is possible or necessary (a delivery cannot be "un-delivered"), there is no pivot, and no step depends on the result of another: it is the simple, stable reaction for which choreography is a good fit. - TCC: the last three bottles are a scarce, contended resource, and the charge may take minutes because of strong customer authentication.
inventorydoes atry(a provisional reservation with an expiry, say 15 minutes),paymentsdoes atry(a pre-authorisation pending the customer's confirmation); when the bank confirms, the orchestrator runsconfirmon both (consume the reservation, capture the charge); if Lucy does not confirm in time,cancelon both. During the wait, the catalogue shows the bottles as reserved, neither sold nor available: the explicit provisional state of section 10. A plain saga with an immediate charge cannot wait minutes with the stock deducted without it appearing as sold, and 2PC cannot include the gateway.
Conclusion
The monolith's transaction, with its all-or-nothing COMMIT, does not exist once the order crosses orders, inventory and payments, and this lesson has shown the two ways of living without it. Two-phase commit preserves atomicity with a coordinator that collects votes in the prepare phase and broadcasts the decision in the commit phase; PostgreSQL supports it with PREPARE TRANSACTION, and we have seen it lock rows indefinitely when the coordinator dies with the participants prepared. That blocking, together with the coupling, the latency and the impossibility of including gateways and brokers, is the reason microservices avoid 2PC (3PC does not help without a synchronous network; the real remedy is a coordinator replicated through consensus, which only distributed databases can afford). The saga replaces atomicity with a sequence of local transactions with compensations, ordered into compensatable, pivot and retriable steps. We have implemented it as a choreography, with idempotent consumers and the outbox chaining order.created, stock.reserved, payment.rejected and stock.released, and as an orchestration in order_saga.py, with the sagas table of km0_orders persisting every transition and a re-entrant resume that picks the saga up after a restart thanks to the idempotency of reservation_id and of the charge key inherited from Module 2; the simulation compensated Mark's reservation when payments rejected him and left the stock intact. The table in section 9 justifies the choice of orchestration for the Kilometre Zero order, and section 10 has put a name to what the saga loses, isolation, with countermeasures (semantic lock, provisional states, pessimistic view, commutative updates) and with TCC as a variant for contended resources.
This lesson closes Module 3. We can now say precisely what a replicated store guarantees (consistency models), what has to be sacrificed when the network splits and what consistency costs when it does not (CAP and PACELC), how a group of nodes elects a leader and agrees on values (Paxos, Raft, etcd), how data is copied between nodes and with which anomalies (leader-follower, multi-leader, quorums), and how a business operation crosses several services without a global transaction (2PC and sagas). All of this treats data as though it were already somewhere. Module 4 deals with that somewhere: where and how the orders, the product photos and the events we now know how to replicate and coordinate are physically stored, when there are too many of them for a single node. It starts with the question that comes before any replica: how to spread different data across many nodes, with partitioning and consistent hashing.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
