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

  1. ACID and the transaction that no longer exists
  2. Two-phase commit (2PC)
  3. Why 2PC blocks, and 3PC
  4. Real-world 2PC: XA and PREPARE TRANSACTION in PostgreSQL
  5. Why microservices avoid 2PC
  6. Sagas: local transactions with compensations
  7. Choreography: the saga as a chain of events
  8. Orchestration: order_saga.py with persisted state
  9. Choreography vs orchestration table
  10. Compensations, idempotency and the lost isolation; TCC
  11. Common mistakes and tips
  12. Exercises
  13. Conclusion

  1. 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.

  1. 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.

  1. 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.

  1. Real-world 2PC: XA and PREPARE TRANSACTION in PostgreSQL

The 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:

SELECT gid, prepared, owner, database FROM pg_prepared_xacts;
         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.)

  1. 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:

  1. Coupling: the coordinator needs direct transactional access to the databases of inventory and payments, which breaks the rule "each service owns its data" from 01-06. The alternative, having each service expose prepare/commit/abort operations over gRPC, is possible but turns every service into an XA resource manager, with its logs, its recovery and its orphaned transactions.
  2. Availability: the blocking of section 3 means that a crash of orders (the coordinator) leaves rows locked in inventory and payments. 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).
  3. Latency and throughput: two phases with an fsync on every participant, with locks held throughout the exchange, limit the number of orders per second to whatever the slowest participant can bear.
  4. Heterogeneity: payments talks 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.
  5. 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.

  1. 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.

  1. 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.

  1. Orchestration: order_saga.py with persisted state

In 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. _transition saves the saga on every state change, and current_step is incremented only after the step has succeeded. If the process dies between step.action(saga) and _transition, on restart resume(saga_id) will run the same step again, which is why every action must be idempotent: reserve with the same reservation_id does not deduct twice (02-03), charge with the same idempotency_key does not charge twice (02-05). The saga directly inherits the work of Module 2.
  • The identifiers are decided at the start. reservation_id and idempotency_key are derived from the order_id and stored in data on 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; a TransientFailure (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 calls resume on the sagas in progress (the sagas_in_progress index exists for that).
  • Compensations are idempotent too (release on 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.create and the first save of the saga go in a single km0_orders transaction (and, if the saga is driven by events, the first command goes to the outbox in 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.

  1. 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.

  1. 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_RESERVED for hours because payments is 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 the sagas row 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 resume with 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:

  1. Moving 50 units of pink-tomato between the stock tables of inv-bcn and inv-vlc (both are PostgreSQL databases of the same inventory service, with no external gateways).
  2. Recording a delivery: delivery marks the order delivered, orders changes the status, analytics updates the average delivery time, and an email is sent to Anna.
  3. Reserving the last 3 bottles of crianza-wine for 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:

  1. 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 TRANSACTION between them, coordinated by inventory, is appropriate: homogeneous participants, no gateways, a short transaction, and the service itself can resolve orphaned prepared transactions on start-up (by querying pg_prepared_xacts). A saga would be over-engineering.
  2. Choreographed saga: delivery publishes order.delivered in the same transaction as its status change (outbox), and orders, analytics and 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.
  3. TCC: the last three bottles are a scarce, contended resource, and the charge may take minutes because of strong customer authentication. inventory does a try (a provisional reservation with an expiry, say 15 minutes), payments does a try (a pre-authorisation pending the customer's confirmation); when the bank confirms, the orchestrator runs confirm on both (consume the reservation, capture the charge); if Lucy does not confirm in time, cancel on 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

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved