?

Day 0251 minPreface+60 XP

Distributed Transactions — The Saga Pattern.

5 stages16 code

watch it first · newThe whole lesson in a 21-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, we learned about consistency models — how to reason about when reads see writes in distributed systems. We discussed strong consistency, eventual consistency, and everything in between.

But there's a harder problem we glossed over:

The distributed transaction problem
Single database transaction
sql4 lines
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
  UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;

If anything fails → ROLLBACK (automatic). Money never disappears. ACID guarantees.

Microservices transaction:

DiagramMicroservices transaction
%% title: Microservices transaction
flowchart TD
  A["Account Service<br/>Debit $100<br/>Done"]:::ok
  P["Payment Service<br/>Charge Card<br/>Done"]:::ok
  I["Inventory Service<br/>Reserve Item<br/>FAILED"]:::bad
  A --> P --> I

Inventory service failed AFTER:

  • Account was debited
  • Card was charged

We can't just ROLLBACK — these are different databases!

What do we do?

This is the problem that Sagas solve.

Today, you'll learn:

  • Why traditional distributed transactions (2PC) don't work in microservices
  • The Saga pattern for distributed transactions
  • Choreography vs Orchestration approaches
  • How to design compensation logic
  • Handling failures in compensation
ExplainerThe whole lesson: the saga pattern

Across microservices a transfer can debit and charge, then fail at inventory, with nothing to roll back, and two-phase commit blocks with locks held and participants in doubt if the coordinator crashes. A saga runs local transactions (reserve inventory, pay, ship, notify) and, on failure, compensating transactions in reverse; shipping is the pivot, after which it retries forward. Compensations are semantic, idempotent and never fail; choreography suits 2–4 steps, orchestration is the default beyond that, and Airbnb's booking waits up to 24 hours for the host before capturing payment.

Part I · Stage 1 of 5 · 9 min

Part I: Foundations#

Chapter 1: The Problem with Distributed Transactions#

1.1 What We Want: ACID Across Services#

ACID properties
  • A - Atomicity: All operations succeed or all fail
  • C - Consistency: Data remains valid after transaction
  • I - Isolation: Concurrent transactions don't interfere
  • D - Durability: Committed data survives failures

In a single database, we get these for free. In microservices, each service has its own database. How do we get ACID across services?

1.2 The Traditional Solution: Two-Phase Commit (2PC)#

DiagramTwo-phase commit protocol
%% title: Two-phase commit protocol
sequenceDiagram
  autonumber
  participant Co as Coordinator
  participant A as Service A
  participant B as Service B
  participant C as Service C
  Note over Co,C: Phase 1: PREPARE
  Co->>A: PREPARE
  A-->>Co: READY
  Co->>B: PREPARE
  B-->>Co: READY
  Co->>C: PREPARE
  C-->>Co: READY
  alt Phase 2: COMMIT (if all ready)
    Co->>A: COMMIT
    A-->>Co: ACK
    Co->>B: COMMIT
    B-->>Co: ACK
    Co->>C: COMMIT
    C-->>Co: ACK
  else Phase 2: ABORT (if any not ready)
    Co->>A: ABORT
    Co->>B: ABORT
    Co->>C: ABORT
    Note over A,C: All participants · everyone rolls back
  end
Your turn · checkpoint 1not answered

Why not just use two-phase commit?

The order flow touches your inventory database, a message broker and a third-party card API, each with its own storage. Two-phase commit would give you atomicity across all three on paper. Name two reasons it would hurt here, even on a day when nothing crashes.

Think it through, or jot it down. Then compare.

One strong answer

  • Locks held across the network: every participant keeps its locks from PREPARE until the decision arrives, so each transaction runs at the pace of the slowest participant and blocks others meanwhile.
  • Everyone must be up at once: one slow or unreachable service stalls the whole transaction.
  • It isn't on offer: many brokers and NoSQL stores don't support XA/2PC, and you can't ask a card API to "prepare" a charge.
  • And on a bad day a coordinator crash after PREPARE leaves participants waiting with their locks held.

1.3 Why 2PC Fails in Microservices#

Problems with 2PC
  1. Blocking protocol
    • Participants HOLD LOCKS from PREPARE until the decision arrives
    • If coordinator crashes after PREPARE, participants wait (potentially indefinitely) until it recovers
    • Resources locked, other transactions blocked
    • System throughput collapses
  2. Coordinator is single point of failure
    • Coordinator crashes mid-transaction
    • Participants don't know whether to commit or abort
    • Need complex recovery protocols
    • Operators forcing "heuristic" commit/abort on in-doubt transactions can leave data inconsistent
  3. Synchronous communication
    • All services must be available simultaneously
    • One slow service slows entire transaction
    • Network partition = stuck transaction
    • Violates microservices independence
  4. Doesn't scale
    • Lock duration proportional to slowest participant
    • Adding services increases failure probability
    • Every transaction pays two round trips plus forced log writes, gated by the slowest participant
    • Business transactions can span many services
  5. Not supported by most services
    • Many NoSQL databases don't support XA/2PC
    • Many message brokers don't support XA/2PC (Kafka doesn't; some JMS brokers do)
    • Third-party APIs don't support 2PC
    • You can't tell Stripe to "prepare" a charge

1.4 The Alternative: Sagas#

Instead of: "Lock everything, do everything, unlock everything"

Sagas say: "Do each step. If a step fails, UNDO previous steps."

  • Each step is a LOCAL transaction (fast, independent)
  • If step N fails, run COMPENSATING transactions for steps 1..N-1
  • No distributed locks, no coordinator holding locks across services
DiagramSaga structure
%% title: Saga structure
flowchart TD
  T1["T1"]:::core --> T2["T2"]:::core --> T3["T3"]:::core --> T4["T4"]:::core --> S(["SUCCESS"]):::ok
  T1 -.-> C1["C1 (undo)"]:::bad
  T2 -.-> C2["C2 (undo)"]:::bad
  T3 -.-> C3["C3 (undo)"]:::bad
  T4 -.-> C4["C4 (undo)"]:::bad
DiagramIf T3 fails
%% title: If T3 fails
%% steps:
%% T1 --> T2, T2 --> T3: T1 and T2 commit, then T3 fails
%% T3 --> C3: C3 isn't needed: T3 never completed
%% C3 --> C2: Compensate in reverse order: C2 undoes T2
%% C2 --> C1: Then C1 undoes T1
%% C1 --> RB: The saga ends rolled back
flowchart TD
  T1["T1"]:::core --> T2["T2"]:::core --> T3["T3 fail"]:::bad
  T3 --> C3["C3 not needed<br/>(T3 didn't complete)"]:::note
  C3 --> C2["C2"]:::bad
  C2 --> C1["C1"]:::bad
  C1 --> RB(["ROLLED BACK"]):::bad
  1. T1 and T2 commit, then T3 fails
  2. C3 isn't needed: T3 never completed
  3. Compensate in reverse order: C2 undoes T2
  4. Then C1 undoes T1
  5. The saga ends rolled back
Your turn · checkpoint 2not answered

Which step can't be undone?

An order saga runs: reserve inventory, charge payment, ship the order, send a confirmation. For each step, what would undo it? Which step has no sensible undo, and how should a failure after that step be handled differently from a failure before it?

Think it through, or jot it down. Then compare.

One strong answer

  • Undo: release the inventory; refund the payment; the confirmation needs no undo (it's just a notification).
  • Shipping has no undo: you can't unship a parcel. That makes it the pivot: once it commits, the saga must finish.
  • Before the pivot, a failure compensates the completed steps in reverse order. After it, a failure is retried until it succeeds (forward recovery).

Chapter 2: Saga Fundamentals#

2.1 What Is a Saga?#

A Saga is a sequence of local transactions where:

  1. Each transaction updates a single service
  2. Each transaction has a compensating transaction
  3. If any transaction fails, compensating transactions undo previous work
Saga terminology
  • Transaction (Ti): A local database transaction in one service
  • Compensation (Ci): Undoes the effect of Ti (semantically, not literally)
  • Pivot Transaction: Point of no return - after this, saga must complete
  • Retriable Transaction: Safe to retry until success (idempotent)
Example: Order Processing Saga
Step Transaction Compensation
1 T1: Reserve inventory C1: Release inventory
2 T2: Create payment C2: Refund payment
3 T3: Ship order (PIVOT) C3: (None - can't unship!)
4 T4: Send confirmation C4: (None - just notification)

T3 is the pivot - once shipped, we commit to completing. Before T3, we can compensate. After T3, we must proceed.

ExplainerSaga: local steps, compensations, the pivot

An order saga runs Reserve inventory, Create payment, Ship (the pivot) and Send confirmation as local transactions. A failure before the pivot runs the compensations in reverse (refund, then release inventory); after the pivot the saga only moves forward, retrying until it completes.

2.2 Compensation vs Rollback#

Compensation is not rollback

Database rollback

  • Undoes changes as if they never happened
  • Uses transaction log
  • Automatic, built-in

Saga compensation

  • Creates NEW transactions that reverse the effect
  • Business logic, not database feature
  • You must design and implement it

Example: Payment Compensation

  • Original: charge_customer(customer_id, $100) — creates: Payment record, charge on card
  • Compensation: refund_customer(payment_id) — creates: Refund record, credit on card

The original charge STILL EXISTS in history. The refund is a NEW transaction. Net effect: customer not charged. But audit trail shows both events.

Your turn · checkpoint 3not answered

The refund that ran twice

A network timeout makes the orchestrator retry the refund compensation, and the first call had in fact succeeded. In another saga, the refund runs for a charge step that never completed. What goes wrong in each case, and what two properties should the refund have?

Think it through, or jot it down. Then compare.

One strong answer

  • Retry: a naive refund pays the customer back twice. The refund must be idempotent: a compensation ID makes the second call a no-op.
  • Missing original: a naive refund credits money that was never charged. The compensation must check state first and do nothing when there's nothing to undo.
  • Why it matters: compensations are retried by design, so both cases are normal, not rare.

2.3 Designing Compensating Transactions#

Compensation design principles
  1. Semantic reversal
    • Undo the business effect, not the technical operation
    • create_order → cancel_order (not DELETE)
    • reserve_inventory → release_inventory
    • charge_payment → refund_payment
  2. Idempotent
    • Compensation might run multiple times (retries)
    • Running twice should have same effect as once
    • Use compensation IDs to prevent double-refunds
  3. Commutative where possible
    • Order of compensations shouldn't matter
    • Reduces coordination complexity
    • Not always possible (dependencies)
  4. Handle missing original
    • What if T2 compensation runs but T2 never completed?
    • Compensation should check state first
    • Be a no-op if nothing to compensate
  5. Never fail (or be retriable)
    • Compensation failure = stuck saga
    • Design compensations to always succeed
    • If must fail, alert for manual intervention

Chapter 3: Choreography vs Orchestration#

3.1 Choreography: Event-Driven Sagas#

In choreography, each service listens for events and decides what to do next.

No central coordinator. Services react to events.

DiagramOrder saga via choreography
%% title: Order saga via choreography
flowchart TD
  O["Order Service"]:::core
  I["Inventory Service"]:::service
  P["Payment Service"]:::service
  S["Shipping Service"]:::service
  OC(["Order (complete)"]):::ok
  O -.->|"order.created"| I
  I -.->|"inventory.reserved"| P
  P -.->|"payment.completed"| S
  S -.->|"shipping.created"| OC

If Payment fails, the payment.failed event triggers:

  • Inventory listens → releases reservation
  • Order listens → marks order as failed

Pros:

  • Simple for small sagas (2-4 steps)
  • Loose coupling between services
  • No single point of failure

Cons:

  • Hard to understand full saga flow
  • Cyclic dependencies possible
  • Difficult to add/change steps
  • Testing requires all services

3.2 Orchestration: Centralized Coordinator#

In orchestration, a central coordinator tells each service what to do.

Central orchestrator manages the saga flow.

DiagramOrder saga via orchestration
%% title: Order saga via orchestration
flowchart TD
  O["Orchestrator<br/>(Order Saga)"]:::core
  I["Inventory Service"]:::service
  P["Payment Service"]:::service
  S["Shipping Service"]:::service
  O --> I
  O --> P
  O --> S

Flow:

  1. Orchestrator: "Inventory, reserve items"
  2. Inventory: "Reserved" (or "Failed")
  3. Orchestrator: "Payment, charge customer"
  4. Payment: "Charged" (or "Failed")
  5. Orchestrator: "Shipping, create shipment"
  6. Shipping: "Created"
  7. Orchestrator: "Saga complete"

If Payment fails, the orchestrator handles compensation:

  1. Orchestrator: "Inventory, release reservation"
  2. Orchestrator: "Order, mark as failed"

Pros:

  • Easy to understand (linear flow)
  • Centralized saga logic
  • Easy to add/modify steps
  • Better for complex sagas

Cons:

  • Orchestrator is coupling point
  • Orchestrator must be highly available
  • Risk of orchestrator becoming a monolith
Your turn · checkpoint 4not answered

Choreography or orchestration?

Two sagas: (a) order, then payment, then a notification; (b) a trip booking of flight, hotel, car and insurance with branching rules, which support staff must be able to inspect mid-flight. Which coordination style for each, and why?

Think it through, or jot it down. Then compare.

One strong answer

  • (a) Choreography: three steps, services already publish the events, and loose coupling with no central component is cheap at that size.
  • (b) Orchestration: many steps, branching and a need to see saga state call for a coordinator. With events alone the flow is spread over every service, hard to follow and hard to change.
  • Cost of (b): the orchestrator becomes a coupling point that must be highly available.

3.3 When to Use Each#

Choosing between choreography and orchestration

Use CHOREOGRAPHY when

  • Saga has 2-4 steps
  • Services already publish relevant events
  • Teams are independent and don't want coordination
  • Low complexity, well-understood domain

Example: Simple order → payment → notification

Use ORCHESTRATION when

  • Saga has 5+ steps
  • Complex branching logic
  • Need visibility into saga state
  • Multiple sagas share services
  • Need to change saga logic frequently

Example: Travel booking (flight + hotel + car + insurance)

Real-world observation

  • Choreography is fine for short, stable flows
  • Switch to orchestration once the flow becomes hard to follow (this lesson's default for non-trivial sagas)
  • Many companies use both (simple sagas choreographed, complex orchestrated)

Chapter 4: Saga Execution Semantics#

4.1 Forward Recovery vs Backward Recovery#

Recovery strategies
BACKWARD RECOVERY (Compensate) FORWARD RECOVERY (Retry)
On failure Step fails → undo previous steps Step fails → retry until success
Outcome Return system to original state Eventually complete the saga
Use when Failure means "give up" Saga MUST complete
Example Payment failed → cancel order Shipment tracking update (eventually consistent)
Many sagas use BOTH

T1 → T2 → T3 (pivot) → T4 → T5

  • Before pivot: Backward recovery (compensate)
  • After pivot: Forward recovery (retry until success)
Example: E-commerce Order

Reserve Inventory → Charge Payment → Ship (PIVOT) → Notify Customer → Complete Order

  • Before shipping: Can compensate (release inventory, refund)
  • After shipping: Must complete (can't unship, so notify and complete)

4.2 The Saga Execution Coordinator (SEC)#

The SEC is responsible for:

  1. Starting sagas
  2. Tracking saga state
  3. Executing steps (or triggering them)
  4. Handling failures and compensation
  5. Ensuring saga completes (one way or another)
DiagramSEC state machine
%% title: SEC state machine
stateDiagram-v2
  [*] --> STARTED
  STARTED --> EXECUTING
  EXECUTING --> COMPENSATING: step fails
  EXECUTING --> COMPLETED: all steps succeeded
  EXECUTING --> COMPENSATION_STARTED: timeout
  COMPENSATION_STARTED --> COMPENSATING_T
  COMPENSATING --> COMPENSATED: compensation complete
  COMPENSATING_T --> COMPENSATED
  COMPENSATION_STARTED: COMPENSATION STARTED
  COMPENSATING_T: COMPENSATING
  COMPENSATED: COMPENSATED (failed)
  COMPLETED: COMPLETED (succeeded)
  class STARTED,EXECUTING core
  class COMPENSATING,COMPENSATION_STARTED,COMPENSATING_T queue
  class COMPENSATED bad
  class COMPLETED ok
Quick check3 questions · +5 XP each

Why sagas, compensation and pivots

  1. Q1/3 In a 2PC transaction, all three services answered READY and then the coordinator crashed before sending COMMIT. What do the participants experience?

    Answer & explanation

    Answer: B

    2PC is blocking: prepared participants keep their locks and must wait for the coordinator's decision, so other transactions pile up behind them. Committing or aborting unilaterally is unsafe, because a participant cannot know what the coordinator decided for the others.

  2. Q2/3 The order saga is reserve inventory, charge payment, ship (pivot), send confirmation. Shipping succeeded but the confirmation email fails. What should happen?

    Answer & explanation

    Answer: D

    After the pivot the saga is committed to completing, so failures are handled by retrying forward. Compensating would refund a customer whose package is already on its way, and you cannot unship.

  3. Q3/3 Compensation C2 (refund payment) is invoked, but the charge step T2 never actually completed. What should C2 do?

    Answer & explanation

    Answer: A

    Compensations must handle a missing original: check state first and be a no-op when there is nothing to reverse. Refunding blindly could credit money that was never charged, and failing would leave the saga stuck for no reason.

Part II · Stage 2 of 5 · 17 min

Part II: Implementation#

Chapter 5: Choreography Implementation#

Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.

pythonDomainEvent · 446 lines
# Saga Choreography Implementation

import asyncio
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Callable
from datetime import datetime, timezone
from enum import Enum
import json
import logging

logger = logging.getLogger(__name__)


# =============================================================================
# Domain Events
# =============================================================================

@dataclass
class DomainEvent:
    """Base class for domain events."""
    event_id: str
    correlation_id: str  # Saga ID
    # kw_only (Python 3.10+) lets subclasses add required fields after this defaulted one
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc), kw_only=True)
    
    def to_dict(self) -> dict:
        return {
            "event_id": self.event_id,
            "correlation_id": self.correlation_id,
            "timestamp": self.timestamp.isoformat(),
            "event_type": self.__class__.__name__,
            "data": self._event_data()
        }
    
    def _event_data(self) -> dict:
        raise NotImplementedError


@dataclass
class OrderCreated(DomainEvent):
    order_id: str
    customer_id: str
    items: List[dict]
    total_amount: float
    
    def _event_data(self) -> dict:
        return {
            "order_id": self.order_id,
            "customer_id": self.customer_id,
            "items": self.items,
            "total_amount": self.total_amount
        }


@dataclass
class InventoryReserved(DomainEvent):
    order_id: str
    reservation_id: str
    items: List[dict]
    
    def _event_data(self) -> dict:
        return {
            "order_id": self.order_id,
            "reservation_id": self.reservation_id,
            "items": self.items
        }


@dataclass
class InventoryReservationFailed(DomainEvent):
    order_id: str
    reason: str
    
    def _event_data(self) -> dict:
        return {"order_id": self.order_id, "reason": self.reason}


@dataclass
class PaymentCompleted(DomainEvent):
    order_id: str
    payment_id: str
    amount: float
    
    def _event_data(self) -> dict:
        return {
            "order_id": self.order_id,
            "payment_id": self.payment_id,
            "amount": self.amount
        }


@dataclass
class PaymentFailed(DomainEvent):
    order_id: str
    reason: str
    
    def _event_data(self) -> dict:
        return {"order_id": self.order_id, "reason": self.reason}


@dataclass  
class InventoryReleased(DomainEvent):
    order_id: str
    reservation_id: str
    
    def _event_data(self) -> dict:
        return {
            "order_id": self.order_id,
            "reservation_id": self.reservation_id
        }


@dataclass
class OrderCompleted(DomainEvent):
    order_id: str
    
    def _event_data(self) -> dict:
        return {"order_id": self.order_id}


@dataclass
class OrderFailed(DomainEvent):
    order_id: str
    reason: str
    
    def _event_data(self) -> dict:
        return {"order_id": self.order_id, "reason": self.reason}


# =============================================================================
# Event Publisher/Subscriber
# =============================================================================

class EventBus:
    """Simple in-memory event bus for demonstration."""
    
    def __init__(self):
        self._handlers: Dict[str, List[Callable]] = {}
    
    def subscribe(self, event_type: str, handler: Callable):
        if event_type not in self._handlers:
            self._handlers[event_type] = []
        self._handlers[event_type].append(handler)
    
    async def publish(self, event: DomainEvent):
        event_type = event.__class__.__name__
        logger.info(f"Publishing event: {event_type} for saga {event.correlation_id}")
        
        handlers = self._handlers.get(event_type, [])
        for handler in handlers:
            try:
                await handler(event)
            except Exception as e:
                logger.error(f"Handler failed for {event_type}: {e}")


# =============================================================================
# Services (Choreography Participants)
# =============================================================================

class OrderService:
    """Order service - initiates and tracks order saga."""
    
    def __init__(self, db, event_bus: EventBus):
        self.db = db
        self.events = event_bus
        
        # Subscribe to events
        self.events.subscribe("InventoryReserved", self._on_inventory_reserved)
        self.events.subscribe("InventoryReservationFailed", self._on_inventory_failed)
        self.events.subscribe("PaymentCompleted", self._on_payment_completed)
        self.events.subscribe("PaymentFailed", self._on_payment_failed)
    
    async def create_order(self, customer_id: str, items: List[dict]) -> str:
        """Create order and start saga via event."""
        order_id = generate_id()
        total = sum(item['price'] * item['quantity'] for item in items)
        
        # Save order in PENDING state
        await self.db.execute(
            """
            INSERT INTO orders (id, customer_id, items, total, status, created_at)
            VALUES ($1, $2, $3, $4, 'PENDING', NOW())
            """,
            order_id, customer_id, json.dumps(items), total
        )
        
        # Publish event to start saga
        event = OrderCreated(
            event_id=generate_id(),
            correlation_id=order_id,  # Order ID is the saga ID
            order_id=order_id,
            customer_id=customer_id,
            items=items,
            total_amount=total
        )
        await self.events.publish(event)
        
        return order_id
    
    async def _on_inventory_reserved(self, event: InventoryReserved):
        """Handle inventory reserved - update order state."""
        await self.db.execute(
            """
            UPDATE orders 
            SET status = 'INVENTORY_RESERVED', reservation_id = $2
            WHERE id = $1
            """,
            event.order_id, event.reservation_id
        )
    
    async def _on_inventory_failed(self, event: InventoryReservationFailed):
        """Handle inventory failure - fail order."""
        await self.db.execute(
            "UPDATE orders SET status = 'FAILED', failure_reason = $2 WHERE id = $1",
            event.order_id, event.reason
        )
        
        await self.events.publish(OrderFailed(
            event_id=generate_id(),
            correlation_id=event.correlation_id,
            order_id=event.order_id,
            reason=event.reason
        ))
    
    async def _on_payment_completed(self, event: PaymentCompleted):
        """Handle payment completed - complete order."""
        await self.db.execute(
            """
            UPDATE orders 
            SET status = 'COMPLETED', payment_id = $2
            WHERE id = $1
            """,
            event.order_id, event.payment_id
        )
        
        await self.events.publish(OrderCompleted(
            event_id=generate_id(),
            correlation_id=event.correlation_id,
            order_id=event.order_id
        ))
    
    async def _on_payment_failed(self, event: PaymentFailed):
        """Handle payment failure - will trigger compensation."""
        await self.db.execute(
            "UPDATE orders SET status = 'PAYMENT_FAILED', failure_reason = $2 WHERE id = $1",
            event.order_id, event.reason
        )


class InventoryService:
    """Inventory service - reserves and releases inventory."""
    
    def __init__(self, db, event_bus: EventBus):
        self.db = db
        self.events = event_bus
        
        # Subscribe to events
        self.events.subscribe("OrderCreated", self._on_order_created)
        self.events.subscribe("PaymentFailed", self._on_payment_failed)
    
    async def _on_order_created(self, event: OrderCreated):
        """React to order created - try to reserve inventory."""
        try:
            reservation_id = await self._reserve_inventory(
                event.order_id, 
                event.items
            )
            
            await self.events.publish(InventoryReserved(
                event_id=generate_id(),
                correlation_id=event.correlation_id,
                order_id=event.order_id,
                reservation_id=reservation_id,
                items=event.items
            ))
            
        except InsufficientInventoryError as e:
            await self.events.publish(InventoryReservationFailed(
                event_id=generate_id(),
                correlation_id=event.correlation_id,
                order_id=event.order_id,
                reason=str(e)
            ))
    
    async def _on_payment_failed(self, event: PaymentFailed):
        """
        React to payment failed - COMPENSATE by releasing inventory.
        
        This is the compensation transaction triggered by choreography.
        """
        # Find the reservation for this order
        reservation = await self.db.fetch_one(
            "SELECT id FROM inventory_reservations WHERE order_id = $1 AND status = 'active'",
            event.order_id
        )
        
        if reservation:
            await self._release_inventory(reservation['id'])
            
            await self.events.publish(InventoryReleased(
                event_id=generate_id(),
                correlation_id=event.correlation_id,
                order_id=event.order_id,
                reservation_id=reservation['id']
            ))
    
    async def _reserve_inventory(self, order_id: str, items: List[dict]) -> str:
        """Reserve inventory for order items."""
        reservation_id = generate_id()
        
        async with self.db.transaction(isolation="SERIALIZABLE"):
            for item in items:
                # Check and decrement inventory
                result = await self.db.execute(
                    """
                    UPDATE inventory 
                    SET quantity = quantity - $2
                    WHERE product_id = $1 AND quantity >= $2
                    """,
                    item['product_id'], item['quantity']
                )
                
                if result.rowcount == 0:
                    raise InsufficientInventoryError(
                        f"Insufficient inventory for {item['product_id']}"
                    )
            
            # Create reservation record
            await self.db.execute(
                """
                INSERT INTO inventory_reservations (id, order_id, items, status, created_at)
                VALUES ($1, $2, $3, 'active', NOW())
                """,
                reservation_id, order_id, json.dumps(items)
            )
        
        return reservation_id
    
    async def _release_inventory(self, reservation_id: str):
        """Release inventory reservation (compensation)."""
        async with self.db.transaction():
            reservation = await self.db.fetch_one(
                """
                SELECT items FROM inventory_reservations 
                WHERE id = $1 AND status = 'active'
                FOR UPDATE
                """,
                reservation_id
            )
            
            if not reservation:
                logger.warning(f"Reservation {reservation_id} not found or not active")
                return  # Idempotent - already released
            
            items = json.loads(reservation['items'])
            
            for item in items:
                await self.db.execute(
                    "UPDATE inventory SET quantity = quantity + $2 WHERE product_id = $1",
                    item['product_id'], item['quantity']
                )
            
            await self.db.execute(
                "UPDATE inventory_reservations SET status = 'released' WHERE id = $1",
                reservation_id
            )


class PaymentService:
    """Payment service - charges and refunds payments."""
    
    def __init__(self, db, event_bus: EventBus, payment_gateway):
        self.db = db
        self.events = event_bus
        self.gateway = payment_gateway
        
        # Subscribe to events
        self.events.subscribe("InventoryReserved", self._on_inventory_reserved)
    
    async def _on_inventory_reserved(self, event: InventoryReserved):
        """React to inventory reserved - charge payment."""
        # Get order details
        order = await self.db.fetch_one(
            "SELECT customer_id, total FROM orders WHERE id = $1",
            event.order_id
        )
        
        try:
            payment_id = await self._charge_customer(
                order['customer_id'],
                order['total'],
                event.order_id
            )
            
            await self.events.publish(PaymentCompleted(
                event_id=generate_id(),
                correlation_id=event.correlation_id,
                order_id=event.order_id,
                payment_id=payment_id,
                amount=order['total']
            ))
            
        except PaymentError as e:
            await self.events.publish(PaymentFailed(
                event_id=generate_id(),
                correlation_id=event.correlation_id,
                order_id=event.order_id,
                reason=str(e)
            ))
    
    async def _charge_customer(
        self,
        customer_id: str,
        amount: float,
        order_id: str
    ) -> str:
        """Charge customer via payment gateway."""
        payment_id = generate_id()
        
        # Get customer payment method
        customer = await self.db.fetch_one(
            "SELECT payment_method_id FROM customers WHERE id = $1",
            customer_id
        )
        
        # Charge via gateway
        result = await self.gateway.charge(
            payment_method_id=customer['payment_method_id'],
            amount=amount,
            idempotency_key=f"order-{order_id}"  # Idempotent!
        )
        
        if not result.success:
            raise PaymentError(result.error_message)
        
        # Record payment
        await self.db.execute(
            """
            INSERT INTO payments (id, order_id, customer_id, amount, status, gateway_ref)
            VALUES ($1, $2, $3, $4, 'completed', $5)
            """,
            payment_id, order_id, customer_id, amount, result.reference
        )
        
        return payment_id

Chapter 6: Orchestration Implementation#

pythonSagaStepStatus · 568 lines
# Saga Orchestration Implementation

from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any, Callable
from enum import Enum
from datetime import datetime, timedelta, timezone
import asyncio
import json
import logging

logger = logging.getLogger(__name__)


# =============================================================================
# Saga Definition
# =============================================================================

class SagaStepStatus(Enum):
    PENDING = "pending"
    EXECUTING = "executing"
    COMPLETED = "completed"
    FAILED = "failed"
    COMPENSATING = "compensating"
    COMPENSATED = "compensated"


class SagaStatus(Enum):
    STARTED = "started"
    EXECUTING = "executing"
    COMPLETED = "completed"
    COMPENSATING = "compensating"
    COMPENSATED = "compensated"
    FAILED = "failed"


@dataclass
class SagaStep:
    """Definition of a saga step."""
    name: str
    action: Callable  # The forward action
    compensation: Optional[Callable]  # The compensation action
    is_pivot: bool = False  # After pivot, no compensation - must complete
    retryable: bool = True
    max_retries: int = 3
    timeout: timedelta = field(default_factory=lambda: timedelta(seconds=30))


@dataclass
class SagaStepResult:
    """Result of executing a saga step."""
    success: bool
    data: Optional[Dict[str, Any]] = None
    error: Optional[str] = None


@dataclass
class SagaState:
    """Persistent state of a saga execution."""
    saga_id: str
    saga_type: str
    status: SagaStatus
    current_step: int
    input_data: Dict[str, Any]
    step_results: Dict[str, Any]  # Results from each step
    created_at: datetime
    updated_at: datetime
    error: Optional[str] = None


# =============================================================================
# Saga Definition DSL
# =============================================================================

class SagaDefinition:
    """
    Fluent interface for defining sagas.
    
    Usage:
        saga = (SagaDefinition("OrderSaga")
            .step("reserve_inventory", reserve_fn, release_fn)
            .step("charge_payment", charge_fn, refund_fn)
            .step("create_shipment", ship_fn, None, is_pivot=True)
            .step("send_notification", notify_fn, None)
            .build())
    """
    
    def __init__(self, name: str):
        self.name = name
        self.steps: List[SagaStep] = []
    
    def step(
        self,
        name: str,
        action: Callable,
        compensation: Optional[Callable] = None,
        is_pivot: bool = False,
        retryable: bool = True,
        max_retries: int = 3,
        timeout_seconds: int = 30
    ) -> 'SagaDefinition':
        """Add a step to the saga."""
        self.steps.append(SagaStep(
            name=name,
            action=action,
            compensation=compensation,
            is_pivot=is_pivot,
            retryable=retryable,
            max_retries=max_retries,
            timeout=timedelta(seconds=timeout_seconds)
        ))
        return self
    
    def build(self) -> 'Saga':
        """Build the saga."""
        return Saga(self.name, self.steps)


class Saga:
    """Executable saga definition."""
    
    def __init__(self, name: str, steps: List[SagaStep]):
        self.name = name
        self.steps = steps
        
        # Validate: steps after pivot should not have compensation
        pivot_found = False
        for step in steps:
            if step.is_pivot:
                pivot_found = True
            elif pivot_found and step.compensation:
                logger.warning(
                    f"Step {step.name} has compensation after pivot - will be ignored"
                )


# =============================================================================
# Saga Orchestrator
# =============================================================================

class SagaOrchestrator:
    """
    Central orchestrator for executing sagas.
    
    Responsibilities:
    - Execute saga steps in order
    - Persist saga state for recovery
    - Handle failures and compensation
    - Retry failed steps
    """
    
    def __init__(self, db, sagas: Dict[str, Saga]):
        self.db = db
        self.sagas = sagas  # Registered saga definitions
        self._tasks = set()  # strong references: the event loop only keeps weak ones

    def _spawn(self, coro):
        """Run a coroutine in the background without letting it be garbage-collected."""
        task = asyncio.create_task(coro)
        self._tasks.add(task)
        task.add_done_callback(self._tasks.discard)
        return task
    
    async def start_saga(
        self,
        saga_type: str,
        input_data: Dict[str, Any]
    ) -> str:
        """Start a new saga execution."""
        saga = self.sagas.get(saga_type)
        if not saga:
            raise ValueError(f"Unknown saga type: {saga_type}")
        
        saga_id = generate_id()
        
        state = SagaState(
            saga_id=saga_id,
            saga_type=saga_type,
            status=SagaStatus.STARTED,
            current_step=0,
            input_data=input_data,
            step_results={},
            created_at=datetime.now(timezone.utc),
            updated_at=datetime.now(timezone.utc)
        )
        
        await self._save_state(state)
        
        # Execute asynchronously
        self._spawn(self._execute_saga(state))
        
        return saga_id
    
    async def _execute_saga(self, state: SagaState):
        """Execute the saga steps."""
        saga = self.sagas[state.saga_type]
        
        state.status = SagaStatus.EXECUTING
        await self._save_state(state)
        
        try:
            # Execute each step
            for i, step in enumerate(saga.steps):
                if step.name in state.step_results:
                    continue  # Already completed before a crash (recovery)
                state.current_step = i
                await self._save_state(state)
                
                result = await self._execute_step(step, state)
                
                if result.success:
                    state.step_results[step.name] = result.data
                    await self._save_step_result(state, i, step.name, result.data)
                    await self._save_state(state)
                else:
                    # Step failed
                    state.error = result.error
                    await self._save_state(state)
                    
                    if self._past_pivot(saga, i):
                        # Pivot already committed - must complete with forward recovery
                        # (a failed pivot never committed, so we still compensate)
                        await self._forward_recovery(saga, state, i)
                    else:
                        # Before pivot - compensate
                        await self._compensate(saga, state, i)
                    return
            
            # All steps completed successfully
            state.status = SagaStatus.COMPLETED
            await self._save_state(state)
            logger.info(f"Saga {state.saga_id} completed successfully")
            
        except Exception as e:
            logger.error(f"Saga {state.saga_id} failed with error: {e}")
            state.status = SagaStatus.FAILED
            state.error = str(e)
            await self._save_state(state)
    
    async def _execute_step(
        self,
        step: SagaStep,
        state: SagaState
    ) -> SagaStepResult:
        """Execute a single saga step with retry logic."""
        last_error = None
        
        for attempt in range(step.max_retries):
            try:
                # Build step context with all previous results
                context = {
                    "saga_id": state.saga_id,
                    "input": state.input_data,
                    "results": state.step_results
                }
                
                # Execute with timeout
                result = await asyncio.wait_for(
                    step.action(context),
                    timeout=step.timeout.total_seconds()
                )
                
                return SagaStepResult(success=True, data=result)
                
            except asyncio.TimeoutError:
                last_error = f"Step {step.name} timed out"
                logger.warning(f"{last_error}, attempt {attempt + 1}/{step.max_retries}")
                
            except Exception as e:
                last_error = str(e)
                logger.warning(
                    f"Step {step.name} failed: {e}, attempt {attempt + 1}/{step.max_retries}"
                )
            
            if attempt < step.max_retries - 1 and step.retryable:
                # Exponential backoff
                await asyncio.sleep(2 ** attempt)
        
        return SagaStepResult(success=False, error=last_error)
    
    async def _compensate(self, saga: Saga, state: SagaState, failed_step: int):
        """Execute compensation for completed steps in reverse order."""
        state.status = SagaStatus.COMPENSATING
        await self._save_state(state)
        
        logger.info(f"Starting compensation for saga {state.saga_id}")
        
        # Compensate in reverse order, excluding the failed step
        for i in range(failed_step - 1, -1, -1):
            step = saga.steps[i]
            
            if step.compensation is None:
                logger.info(f"No compensation for step {step.name}")
                continue
            
            if step.is_pivot:
                # Don't compensate pivot or steps before it
                logger.info(f"Reached pivot step {step.name}, stopping compensation")
                break
            
            await self._execute_compensation(step, state)
        
        state.status = SagaStatus.COMPENSATED
        await self._save_state(state)
        logger.info(f"Saga {state.saga_id} compensated")
    
    async def _execute_compensation(self, step: SagaStep, state: SagaState):
        """Execute compensation for a single step."""
        context = {
            "saga_id": state.saga_id,
            "input": state.input_data,
            "results": state.step_results,
            "step_result": state.step_results.get(step.name)
        }
        
        # Compensation should not fail - retry until success
        for attempt in range(10):  # More retries for compensation
            try:
                await asyncio.wait_for(
                    step.compensation(context),
                    timeout=step.timeout.total_seconds() * 2
                )
                logger.info(f"Compensated step {step.name}")
                await self._record_compensation(state, step.name, "completed")
                return
                
            except Exception as e:
                logger.error(
                    f"Compensation for {step.name} failed: {e}, "
                    f"attempt {attempt + 1}/10"
                )
                await asyncio.sleep(2 ** attempt)
        
        # Compensation failed - this is bad!
        logger.critical(
            f"COMPENSATION FAILED for step {step.name} in saga {state.saga_id}. "
            f"Manual intervention required!"
        )
        # Hand it to the recovery job (Chapter 12), alert operations team, etc.
        await self._record_compensation(state, step.name, "failed")
    
    async def _forward_recovery(
        self,
        saga: Saga,
        state: SagaState,
        failed_step: int
    ):
        """Retry steps after pivot until success."""
        logger.info(f"Forward recovery for saga {state.saga_id} from step {failed_step}")
        
        for i in range(failed_step, len(saga.steps)):
            step = saga.steps[i]
            
            # Retry indefinitely until success (with backoff)
            for attempt in range(100):  # Effectively unlimited
                result = await self._execute_step(step, state)
                
                if result.success:
                    state.step_results[step.name] = result.data
                    await self._save_step_result(state, i, step.name, result.data)
                    await self._save_state(state)
                    break
                
                wait_time = min(300, 2 ** attempt)  # Cap at 5 minutes
                logger.warning(
                    f"Forward recovery step {step.name} failed, "
                    f"retrying in {wait_time}s"
                )
                await asyncio.sleep(wait_time)
        
        state.status = SagaStatus.COMPLETED
        await self._save_state(state)
    
    def _past_pivot(self, saga: Saga, step_index: int) -> bool:
        """Check if we're past the pivot point."""
        for i in range(step_index):
            if saga.steps[i].is_pivot:
                return True
        return False
    
    async def _save_state(self, state: SagaState):
        """Persist saga state to database (schema in Chapter 12)."""
        state.updated_at = datetime.now(timezone.utc)
        
        await self.db.execute(
            """
            INSERT INTO saga_instances (saga_id, saga_type, status, current_step, 
                                       input_data, error_message, created_at, updated_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
            ON CONFLICT (saga_id) DO UPDATE SET
                status = $3, current_step = $4, 
                error_message = $6, updated_at = $8
            """,
            state.saga_id, state.saga_type, state.status.value, state.current_step,
            json.dumps(state.input_data),
            state.error, state.created_at, state.updated_at
        )
    
    async def _save_step_result(self, state: SagaState, index: int, name: str, data):
        """Record a completed step; recovery rebuilds step results from these rows."""
        await self.db.execute(
            """
            INSERT INTO saga_step_executions
                (id, saga_id, step_name, step_index, status, output_data, completed_at)
            VALUES ($1, $2, $3, $4, 'completed', $5, NOW())
            """,
            generate_id(), state.saga_id, name, index, json.dumps(data)
        )
    
    async def _record_compensation(self, state: SagaState, step_name: str, status: str):
        """Record a compensation outcome; the recovery job retries 'failed' ones."""
        await self.db.execute(
            """
            INSERT INTO saga_compensations (id, saga_id, step_name, status, completed_at, next_retry_at)
            VALUES ($1, $2, $3, $4,
                    CASE WHEN $4 = 'completed' THEN NOW() END,
                    CASE WHEN $4 = 'failed' THEN NOW() END)
            ON CONFLICT (saga_id, step_name) DO UPDATE SET
                status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,
                next_retry_at = EXCLUDED.next_retry_at
            """,
            generate_id(), state.saga_id, step_name, status
        )
    
    async def recover_sagas(self):
        """
        Recover incomplete sagas after restart.
        
        Called on startup to resume interrupted sagas.
        """
        incomplete = await self.db.fetch(
            """
            SELECT * FROM saga_instances 
            WHERE status IN ('started', 'executing', 'compensating')
            """
        )
        
        for row in incomplete:
            steps = await self.db.fetch(
                """
                SELECT step_name, output_data FROM saga_step_executions
                WHERE saga_id = $1 AND status = 'completed'
                """,
                row['saga_id']
            )
            state = SagaState(
                saga_id=row['saga_id'],
                saga_type=row['saga_type'],
                status=SagaStatus(row['status']),
                current_step=row['current_step'],
                input_data=json.loads(row['input_data']),
                step_results={s['step_name']: json.loads(s['output_data']) for s in steps},
                created_at=row['created_at'],
                updated_at=row['updated_at'],
                error=row['error_message']
            )
            
            logger.info(f"Recovering saga {state.saga_id} from step {state.current_step}")
            if state.status == SagaStatus.COMPENSATING:
                saga = self.sagas[state.saga_type]
                self._spawn(self._compensate(saga, state, state.current_step))
            else:
                self._spawn(self._execute_saga(state))


# =============================================================================
# Order Saga Definition
# =============================================================================

class OrderSagaSteps:
    """Step implementations for order saga."""
    
    def __init__(self, inventory_service, payment_service, shipping_service, notification_service):
        self.inventory = inventory_service
        self.payment = payment_service
        self.shipping = shipping_service
        self.notification = notification_service
    
    async def reserve_inventory(self, context: dict) -> dict:
        """Step 1: Reserve inventory."""
        order = context['input']
        
        reservation_id = await self.inventory.reserve(
            order_id=context['saga_id'],
            items=order['items']
        )
        
        return {"reservation_id": reservation_id}
    
    async def release_inventory(self, context: dict):
        """Compensation for reserve_inventory."""
        reservation_id = context['results'].get('reserve_inventory', {}).get('reservation_id')
        
        if reservation_id:
            await self.inventory.release(reservation_id)
    
    async def charge_payment(self, context: dict) -> dict:
        """Step 2: Charge payment."""
        order = context['input']
        
        payment_id = await self.payment.charge(
            customer_id=order['customer_id'],
            amount=order['total'],
            idempotency_key=f"saga-{context['saga_id']}"
        )
        
        return {"payment_id": payment_id}
    
    async def refund_payment(self, context: dict):
        """Compensation for charge_payment."""
        payment_id = context['results'].get('charge_payment', {}).get('payment_id')
        
        if payment_id:
            await self.payment.refund(
                payment_id=payment_id,
                idempotency_key=f"refund-{context['saga_id']}"
            )
    
    async def create_shipment(self, context: dict) -> dict:
        """Step 3: Create shipment (PIVOT - no compensation after this)."""
        order = context['input']
        
        shipment_id = await self.shipping.create_shipment(
            order_id=context['saga_id'],
            items=order['items'],
            address=order['shipping_address']
        )
        
        return {"shipment_id": shipment_id}
    
    async def send_notification(self, context: dict) -> dict:
        """Step 4: Send confirmation notification."""
        order = context['input']
        shipment_id = context['results']['create_shipment']['shipment_id']
        
        await self.notification.send_order_confirmation(
            customer_id=order['customer_id'],
            order_id=context['saga_id'],
            shipment_id=shipment_id
        )
        
        return {"notification_sent": True}


def create_order_saga(steps: OrderSagaSteps) -> Saga:
    """Create the order processing saga."""
    return (SagaDefinition("OrderSaga")
        .step(
            "reserve_inventory",
            steps.reserve_inventory,
            steps.release_inventory
        )
        .step(
            "charge_payment",
            steps.charge_payment,
            steps.refund_payment
        )
        .step(
            "create_shipment",
            steps.create_shipment,
            None,  # No compensation - this is the pivot
            is_pivot=True
        )
        .step(
            "send_notification",
            steps.send_notification,
            None  # Notification doesn't need compensation
        )
        .build())
Quick check3 questions · +5 XP each

Choreography and orchestration in code

  1. Q1/3 In the choreographed order saga, the Payment Service publishes PaymentFailed. Who releases the reserved inventory?

    Answer & explanation

    Answer: C

    In choreography nobody issues commands; each service reacts to events. Inventory listens for PaymentFailed and runs its own release. A central coordinator that calls services is orchestration, which is exactly what choreography avoids.

  2. Q2/3 The orchestrator crashes after the payment gateway charged the card but before the step result was saved. On restart, recover_sagas re-runs the charge step. What prevents a double charge?

    Answer & explanation

    Answer: B

    The crash happened between the side effect and the state save, so the step genuinely runs again. The idempotency key makes that retry safe. Persisting state narrows the window but cannot close it; only idempotent steps make at-least-once execution harmless.

  3. Q3/3 In the orchestrated order saga, charge_payment (step 2, before the pivot) fails after all its retries. What does the orchestrator do?

    Answer & explanation

    Answer: D

    Compensation runs in reverse over the steps that completed, excluding the step that failed, since there is no charge to refund. Forward recovery is only used once the pivot has committed; before that, a failure means give up and undo.

Part III · Stage 3 of 5 · 4 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 Case Study: Uber Trip Saga#

A trip involves multiple services that must coordinate:

DiagramUber trip saga
%% title: Uber trip saga
flowchart TD
  subgraph FWD["Forward flow"]
    direction TB
    M["Matching Service<br/>Match rider to driver"]:::service
    PR["Pricing Service<br/>Calculate fare and hold funds"]:::service
    D["Dispatch Service<br/>Assign driver and notify"]:::hot
    M --> PR --> D
  end
  subgraph CMP["Compensation (if dispatch fails)"]
    direction TB
    PRC["Pricing Service<br/>Release held funds"]:::bad
    MC["Matching Service<br/>Unmatch rider and driver"]:::bad
    PRC --> MC
  end
  D -.->|"dispatch fails"| PRC

KEY DECISIONS (illustrative design, not Uber's documented internals):

  • Dispatch is the pivot point (driver is on the way)
  • After dispatch, must complete (pay driver even if rider cancels)
  • Before dispatch, can compensate (no charge to rider)
  • Uses orchestration (centralized trip service)

7.2 Case Study: Airbnb Booking Saga#

Airbnb booking saga
Steps
Step Transaction Compensation
1 T1: Hold dates on calendar C1: Release dates
2 T2: Pre-authorize payment C2: Void pre-auth
3 T3: Confirm with host (async) C3: Notify cancellation
4 T4: Capture payment (PIVOT) —
5 T5: Send confirmation emails —
Interesting aspects
  1. Async host confirmation
    • Host has 24 hours to accept
    • Saga enters "waiting" state
    • Timer triggers compensation if host doesn't respond
    • Implement with saga timeout/deadline
  2. Pivot at payment capture
    • Once payment captured, booking is confirmed
    • Cancellation after capture = refund (different flow)
    • Forward recovery ensures emails sent
  3. Idempotency
    • Pre-auth must be idempotent (same key = same auth)
    • Calendar hold must be idempotent
    • Confirmation email should have dedup

7.3 Common Patterns Across Companies#

Illustrative patterns (simplified; not official descriptions of these companies' internal systems, except Netflix's open-source Conductor):

Company Saga Type Pivot Point Key Learning
Uber Orchestrated Driver dispatch Compensate matching before dispatch
Airbnb Hybrid Payment capture Async steps with timeouts
Stripe Orchestrated Charge capture Idempotency keys for all steps
DoorDash Choreographed Restaurant acceptance Events for loose coupling
Netflix Orchestrated (Conductor) N/A (internal) Workflow visualization

Chapter 8: Common Mistakes#

8.1 Mistake 1: Compensation That Can Fail#

Mistake 1: compensation that can fail

WRONG: Compensation with external dependency

pythonrefund_payment · 8 lines
async def refund_payment(context):
    """Compensation that calls external API."""
    payment_id = context['results']['charge_payment']['payment_id']
    
    # This can fail! Network error, API down, etc.
    await stripe.refunds.create(payment_intent=payment_id)
    
    # If this fails, saga is stuck in partial state!

CORRECT: Idempotent compensation with retries

pythonrefund_payment · 33 lines
async def refund_payment(context):
    """Idempotent compensation with guaranteed completion."""
    payment_id = context['results']['charge_payment']['payment_id']
    saga_id = context['saga_id']
    
    # Idempotency key ensures we can retry safely
    idempotency_key = f"refund-{saga_id}-{payment_id}"
    
    # Record intent to refund FIRST (in our database)
    await db.execute(
        """
        INSERT INTO pending_refunds (saga_id, payment_id, status)
        VALUES ($1, $2, 'pending')
        ON CONFLICT (saga_id, payment_id) DO NOTHING
        """,
        saga_id, payment_id
    )
    
    # Background job will retry until Stripe confirms
    # Even if this call fails, job will pick it up
    try:
        await stripe.refunds.create(
            payment_intent=payment_id,
            idempotency_key=idempotency_key
        )
        
        await db.execute(
            "UPDATE pending_refunds SET status = 'completed' WHERE saga_id = $1",
            saga_id
        )
    except Exception as e:
        logger.warning(f"Refund failed, will retry: {e}")
        # Background job handles retry

8.2 Mistake 2: Not Handling Partial Compensation#

Mistake 2: not handling partial compensation

WRONG: Assuming compensation is all-or-nothing

pythoncompensate_order · 8 lines
async def compensate_order(saga_id):
    """Compensate all steps - but what if one fails?"""
    await release_inventory(saga_id)  # Success
    await refund_payment(saga_id)     # FAILS! 
    await cancel_shipment(saga_id)    # Never runs!
    
    # Inventory released, but payment not refunded
    # Inconsistent state!

CORRECT: Track compensation progress

pythoncompensate_order · 24 lines
async def compensate_order(saga_id):
    """Compensate with progress tracking."""
    compensation_steps = [
        ("inventory", release_inventory),
        ("payment", refund_payment),
        ("shipment", cancel_shipment),
    ]
    
    for step_name, compensation_fn in compensation_steps:
        # Check if already compensated
        status = await get_compensation_status(saga_id, step_name)
        
        if status == 'completed':
            continue
        
        try:
            await compensation_fn(saga_id)
            await mark_compensation_complete(saga_id, step_name)
        except Exception as e:
            # Log and continue - will retry later
            logger.error(f"Compensation {step_name} failed: {e}")
            await mark_compensation_failed(saga_id, step_name, str(e))
    
    # Background job retries failed compensations

8.3 Mistake 3: Non-Idempotent Steps#

Mistake 3: non-idempotent steps

WRONG: Step that charges multiple times on retry

pythoncharge_payment · 13 lines
async def charge_payment(context):
    """Non-idempotent payment - dangerous!"""
    customer_id = context['input']['customer_id']
    amount = context['input']['total']
    
    # If this times out and retries, customer charged twice!
    # (Stripe's Charges API is deprecated in favour of PaymentIntents; kept for illustration)
    result = await stripe.charges.create(
        amount=amount,
        customer=customer_id
    )
    
    return {"charge_id": result.id}

CORRECT: Idempotent step with idempotency key

pythoncharge_payment · 17 lines
async def charge_payment(context):
    """Idempotent payment - safe to retry."""
    customer_id = context['input']['customer_id']
    amount = context['input']['total']
    saga_id = context['saga_id']
    
    # Same key = same charge, even if called multiple times
    idempotency_key = f"charge-{saga_id}"
    
    # (Charges API is deprecated; with PaymentIntents the same idempotency-key idea applies)
    result = await stripe.charges.create(
        amount=amount,
        customer=customer_id,
        idempotency_key=idempotency_key
    )
    
    return {"charge_id": result.id}

8.4 Mistake Checklist#

8.4 Mistake Checklist0/7 ready
  • Every step has idempotency key — Safe to retry
  • Compensation recorded before execution — Can resume
  • Compensation steps are idempotent — Safe to retry compensation
  • Pivot point is clearly defined — Know when to compensate vs forward recover
  • Timeouts on all external calls — Don't hang forever
  • Saga state is persisted — Can recover after crash
  • Compensation has unlimited retries — Must eventually succeed
Quick check3 questions · +5 XP each

Sagas in the real world

  1. Q1/3 In the Airbnb booking saga, a host never responds to a request within the 24-hour window. What should the saga do?

    Answer & explanation

    Answer: C

    Async steps need a timeout. When the deadline passes, the saga is still before its pivot (payment capture), so it compensates. Retrying forward makes no sense for a human decision, and capturing payment would cross the pivot without the host's consent.

  2. Q2/3 Your refund compensation calls the payment provider directly and sometimes fails on network errors, leaving sagas half-compensated. Which fix matches the lesson?

    Answer & explanation

    Answer: A

    Compensation must eventually succeed: persisted intent survives crashes, and the idempotency key makes every retry safe. A local transaction cannot roll back a call to an external API, and giving up after a few tries leaves the customer charged.

  3. Q3/3 During compensation, the inventory release succeeds but the refund throws, so the shipment cancellation never runs. What is the underlying mistake?

    Answer & explanation

    Answer: D

    Track compensation status per step, continue past a failure, and let a background job retry what failed. Reordering does not help: whichever step fails first would still block everything after it.

Part IV · Stage 4 of 5 · 6 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

❯ Say this

9.1 Key Phrases#

Key phrases
Introducing sagas

"Since we have multiple services with separate databases, we can't use a traditional distributed transaction. Instead, I'd use the Saga pattern — a sequence of local transactions with compensating transactions if something fails."

Explaining compensation

"Compensation isn't rollback — it's a new transaction that semantically undoes the previous one. For example, if we charged a customer and then the saga fails, we don't 'rollback' the charge — we issue a refund, which is a new transaction."

On choreography vs orchestration

"For simple sagas with 2-4 steps, choreography works well — each service reacts to events. But for complex flows, I prefer orchestration with a central coordinator. It's easier to understand, modify, and debug."

On failure handling

"The tricky part is compensation failures. If the refund fails, we can't just give up — we need to retry until it succeeds, possibly with human intervention. I'd use idempotency keys and a background job for retrying stuck compensations."

On pivot points

"After a certain point, compensation doesn't make sense. For example, once we've shipped a physical item, we can't 'unship' it. That's the pivot point — after that, we use forward recovery and retry until the saga completes."

9.2 Common Questions#

Question Good Answer
"Why not use 2PC?" "2PC requires holding locks across services while waiting for prepare responses. It's blocking, doesn't scale, and creates a single point of failure. Many NoSQL stores and brokers, and virtually all third-party APIs, don't support it."
"What if compensation fails?" "Compensation must be designed to eventually succeed. Use idempotency keys, persist compensation intent first, and have a background job that retries failed compensations indefinitely. As a last resort, alert for human intervention."
"How do you handle concurrent sagas?" "Each saga operates independently. If they conflict (e.g., same inventory), the first to commit wins. Others fail and compensate. For high-contention resources, consider reservation patterns."
"Choreography or orchestration?" "Depends on complexity. Simple sagas (2-4 steps, clear flow): choreography. Complex sagas (5+ steps, branching, shared services): orchestration. Many teams use both."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Travel Booking Saga#

Setup: Design a saga for booking a trip that includes flight + hotel + car rental.

Requirements:

  • All three must succeed, or none should be booked
  • Each vendor has different cancellation policies
  • Hotels require pre-authorization, flights require immediate payment
  • Users want to see progress

Questions:

  1. What's the order of steps and why?
  2. What's the pivot point?
  3. How do you handle hotel pre-auth expiration?
Solution Outline

Order of steps:

  1. Reserve car (easiest to cancel, hold for 24h)
  2. Pre-authorize hotel (card authorization holds typically last about 7 days online; the exact window varies by card network)
  3. Book flight (PIVOT - immediate charge, expensive to cancel)
  4. Capture hotel payment
  5. Confirm car rental

Pivot point: Flight booking. Airlines charge immediately and have expensive change fees.

Hotel pre-auth expiration:

  • Set saga deadline to 23 hours (before the earliest hold expires: the 24h car hold here; the hotel card authorization typically lasts longer)
  • If saga not complete by deadline, compensate all steps
  • Background job monitors saga deadlines

Practice · problem 2

Problem 2: Subscription Renewal Saga#

Setup: Design a saga for monthly subscription renewal.

Requirements:

  • Charge payment
  • Update subscription status
  • Provision resources (if plan changed)
  • Send receipt email

Questions:

  1. What if payment fails?
  2. What if provisioning fails after payment?
  3. How do you handle retries for declined cards?
Solution Outline

Payment failure:

  • No compensation needed (nothing to undo)
  • Mark subscription as "payment_failed"
  • Send payment failed email
  • Retry payment after 24h (grace period)

Provisioning failure after payment:

  • Refund payment (compensation)
  • Keep subscription on old plan
  • Send failure notification
  • Alert for manual review

Declined cards:

  • Not a saga failure — it's expected
  • Retry with exponential backoff (1, 3, 7 days)
  • After 3 failures, cancel subscription (separate saga)

Chapter 11: Sample Interview Dialogue#

Scenario: Design an Order Processing System#

Transcript
Interviewer

"Let's design an order processing system for an e-commerce site. Walk me through how you'd handle the transaction."

You

"First, let me identify the services involved and their transactions..."

You sketch on the whiteboard:

Order processing steps
  1. Inventory Service: Reserve items
  2. Payment Service: Charge customer
  3. Fulfillment Service: Create shipment
  4. Notification Service: Send confirmation
Transcript
You

"Since each service has its own database, I can't use a traditional database transaction. Instead, I'll use the Saga pattern. Each step is a local transaction, and each has a compensating transaction in case we need to rollback."

Interviewer

"What if the payment fails after inventory is reserved?"

You

"That's exactly why we need compensation. If payment fails, I'd trigger the compensation for the inventory step — release the reservation. The key is that these compensations must be idempotent. If we retry the release multiple times, it should have the same effect as doing it once."

You add to the diagram:

Step Compensation
Reserve inventory Release inventory
Charge payment Refund payment
Create shipment (PIVOT - no compensation)
Send notification (No compensation needed)
Transcript
You

"Notice the shipment step is marked as the pivot. Once we've initiated shipment, we can't really undo it — the package is on its way. So after that point, we use forward recovery instead of compensation. We retry until success."

Interviewer

"How would you implement this? Would you use choreography or orchestration?"

You

"For four steps with a linear flow, either could work. But I'd lean toward orchestration — a central saga coordinator that explicitly calls each service in order. It's easier to add logging, monitoring, and to understand the flow."

You sketch the orchestrator:

DiagramSaga orchestrator
%% title: Saga orchestrator
flowchart TD
  O["Saga Orchestrator"]:::core
  O --> F["Execute forward"]:::ok
  O --> C["Compensate<br/>(on failure)"]:::bad
Transcript
You

"The orchestrator persists its state to a database. If it crashes mid-saga, it can recover and continue from where it left off. Each step is called with an idempotency key, so retries are safe."

Interviewer

"What happens if the compensation itself fails?"

You

"Great question — that's the hardest part. Compensations must be designed to eventually succeed. I'd:

  1. Record the compensation intent in our database BEFORE calling the external service
  2. Use an idempotency key so retries are safe
  3. Have a background job that periodically checks for stuck compensations and retries them
  4. If it still fails after many retries, alert the operations team for manual intervention

The key insight is that compensation failure doesn't mean 'give up' — it means 'keep trying'. We'd rather have a background job retrying a refund for hours than leave a customer incorrectly charged."

Interviewer

"How do you prevent double-charging if the payment step times out?"

You

"Idempotency keys. When I call the payment service, I include a unique key based on the saga ID:

python4 lines
await payment_service.charge(
    amount=order.total,
    idempotency_key=f'saga-{saga_id}-payment'
)

If the call times out and we retry, the payment service recognizes the idempotency key and returns the same result without charging again. Stripe, for example, supports this natively."

Summary#

Day 2 key takeaways
Why sagas
  • 2PC doesn't work in microservices (blocking, no support)
  • Sagas use local transactions + compensation
  • Each step is independent, no distributed locks
Compensation design
  • Semantic reversal, not rollback
  • Must be idempotent (safe to retry)
  • Must eventually succeed (retry forever)
  • Record intent before execution
Choreography vs orchestration
  • Choreography: Events between services, decentralized
    • Good for simple sagas (2-4 steps)
  • Orchestration: Central coordinator, explicit flow
    • Good for complex sagas (5+ steps)
Pivot point
  • Before pivot: Compensate (backward recovery)
  • After pivot: Retry until success (forward recovery)
  • Choose pivot based on business logic
Failure handling
  • Persist saga state for crash recovery
  • Idempotency keys for all external calls
  • Background jobs for stuck compensations
  • Human intervention as last resort
Default approach
  • Start with orchestration (choreography only for short, simple flows)
  • Use idempotency keys everywhere
  • Design compensation for guaranteed success
  • Monitor compensation failure rate

Part V · Stage 5 of 5 · 14 min

Part V: Production Patterns#

Chapter 12: Saga State Persistence#

12.1 Database Schema for Saga State#

sql65 lines
-- Saga State Persistence Schema

-- Main saga state table
CREATE TABLE saga_instances (
    saga_id UUID PRIMARY KEY,
    saga_type VARCHAR(100) NOT NULL,
    status VARCHAR(50) NOT NULL,
    current_step INT NOT NULL DEFAULT 0,
    input_data JSONB NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    completed_at TIMESTAMP,
    error_message TEXT,
    retry_count INT DEFAULT 0
);

-- Indexes for recovery queries (PostgreSQL: indexes are created separately)
CREATE INDEX idx_saga_status ON saga_instances (status);
CREATE INDEX idx_saga_type_status ON saga_instances (saga_type, status);
CREATE INDEX idx_saga_created ON saga_instances (created_at);

-- Step execution history
CREATE TABLE saga_step_executions (
    id UUID PRIMARY KEY,
    saga_id UUID NOT NULL REFERENCES saga_instances(saga_id),
    step_name VARCHAR(100) NOT NULL,
    step_index INT NOT NULL,
    status VARCHAR(50) NOT NULL,
    input_data JSONB,
    output_data JSONB,
    error_message TEXT,
    started_at TIMESTAMP NOT NULL DEFAULT NOW(),
    completed_at TIMESTAMP,
    attempt_number INT NOT NULL DEFAULT 1,
    
    UNIQUE (saga_id, step_name, attempt_number)
);
CREATE INDEX idx_step_saga ON saga_step_executions (saga_id);

-- Compensation tracking
CREATE TABLE saga_compensations (
    id UUID PRIMARY KEY,
    saga_id UUID NOT NULL REFERENCES saga_instances(saga_id),
    step_name VARCHAR(100) NOT NULL,
    status VARCHAR(50) NOT NULL,  -- pending, executing, completed, failed
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    retry_count INT DEFAULT 0,
    last_error TEXT,
    next_retry_at TIMESTAMP,
    
    UNIQUE (saga_id, step_name)
);
CREATE INDEX idx_compensation_retry ON saga_compensations (status, next_retry_at);

-- Idempotency records
CREATE TABLE saga_idempotency_keys (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    saga_id UUID NOT NULL,
    step_name VARCHAR(100) NOT NULL,
    response_data JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_idempotency_expires ON saga_idempotency_keys (expires_at);
Your turn · checkpoint 5not answered

How long does a stuck saga wait?

An orchestrator dies mid-saga and doesn't restart. A recovery job runs every 30 seconds and resumes sagas in started or executing whose last update is more than 5 minutes old. In the worst case, how long after the crash does a stuck saga resume? Why not set the cutoff to 10 seconds?

Think it through, or jot it down. Then compare.

One strong answer

  • Worst case: the saga becomes eligible 5 minutes after its last update, and the job may have just run, so up to ~5.5 minutes (5 min + 30 s).
  • Not 10 seconds: a healthy saga waiting on a slow step (a step may take up to its 30 s timeout, plus retries) would look stuck, and the job would resume it while it's still running, executing steps twice in parallel.
  • So the cutoff trades recovery time for safety; idempotency keys cover the overlaps that slip through.

12.2 Recovery Job Implementation#

pythonSagaRecoveryJob · 181 lines
# Background Job for Saga Recovery

import asyncio
from datetime import datetime, timedelta, timezone
from typing import List


class SagaRecoveryJob:
    """
    Background job that recovers stuck sagas.
    
    Handles:
    - Sagas interrupted by crashes
    - Stuck compensations
    - Timed-out steps
    """
    
    def __init__(self, db, orchestrator: SagaOrchestrator):
        self.db = db
        self.orchestrator = orchestrator
        self.running = False
    
    async def start(self):
        """Start the recovery job loop."""
        self.running = True
        
        while self.running:
            try:
                await self._recover_stuck_sagas()
                await self._retry_failed_compensations()
                await self._cleanup_expired_idempotency_keys()
            except Exception as e:
                logger.error(f"Recovery job error: {e}")
            
            await asyncio.sleep(30)  # Run every 30 seconds
    
    async def stop(self):
        """Stop the recovery job."""
        self.running = False
    
    async def _recover_stuck_sagas(self):
        """Find and recover sagas stuck in executing state."""
        # Find sagas stuck for more than 5 minutes
        cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
        
        stuck_sagas = await self.db.fetch(
            """
            SELECT * FROM saga_instances 
            WHERE status IN ('started', 'executing')
            AND updated_at < $1
            """,
            cutoff
        )
        
        for saga in stuck_sagas:
            logger.info(f"Recovering stuck saga: {saga['saga_id']}")
            
            # Reconstruct state and resume
            state = await self._reconstruct_state(saga)
            self.orchestrator._spawn(self.orchestrator._execute_saga(state))
    
    async def _retry_failed_compensations(self):
        """Retry compensations that failed but are due for retry."""
        now = datetime.now(timezone.utc)
        
        pending = await self.db.fetch(
            """
            SELECT c.*, s.saga_type
            FROM saga_compensations c
            JOIN saga_instances s ON c.saga_id = s.saga_id
            WHERE c.status = 'failed'
            AND c.next_retry_at <= $1
            """,
            now
        )
        
        for comp in pending:
            logger.info(
                f"Retrying compensation: {comp['step_name']} "
                f"for saga {comp['saga_id']}"
            )
            
            try:
                await self._execute_compensation(comp)
                
                await self.db.execute(
                    """
                    UPDATE saga_compensations 
                    SET status = 'completed', completed_at = NOW()
                    WHERE id = $1
                    """,
                    comp['id']
                )
                
            except Exception as e:
                retry_count = comp['retry_count'] + 1
                next_retry = now + timedelta(seconds=min(300, 2 ** retry_count))
                
                await self.db.execute(
                    """
                    UPDATE saga_compensations 
                    SET retry_count = $2, last_error = $3, next_retry_at = $4
                    WHERE id = $1
                    """,
                    comp['id'], retry_count, str(e), next_retry
                )
                
                if retry_count > 10:
                    logger.critical(
                        f"Compensation stuck after {retry_count} retries: "
                        f"{comp['saga_id']}/{comp['step_name']}"
                    )
                    # Alert operations team
    
    async def _cleanup_expired_idempotency_keys(self):
        """Clean up expired idempotency keys."""
        await self.db.execute(
            "DELETE FROM saga_idempotency_keys WHERE expires_at < NOW()"
        )
    
    async def _reconstruct_state(self, saga_row) -> SagaState:
        """Reconstruct saga state from database."""
        step_results = {}
        
        # Get completed step results
        steps = await self.db.fetch(
            """
            SELECT step_name, output_data 
            FROM saga_step_executions 
            WHERE saga_id = $1 AND status = 'completed'
            ORDER BY step_index
            """,
            saga_row['saga_id']
        )
        
        for step in steps:
            step_results[step['step_name']] = step['output_data']
        
        return SagaState(
            saga_id=str(saga_row['saga_id']),
            saga_type=saga_row['saga_type'],
            status=SagaStatus(saga_row['status']),
            current_step=saga_row['current_step'],
            input_data=saga_row['input_data'],
            step_results=step_results,
            created_at=saga_row['created_at'],
            updated_at=saga_row['updated_at'],
            error=saga_row['error_message']
        )
    
    async def _execute_compensation(self, comp: dict):
        """Execute a single compensation."""
        saga = self.orchestrator.sagas.get(comp['saga_type'])
        if not saga:
            raise ValueError(f"Unknown saga type: {comp['saga_type']}")
        
        # Find the step
        step = next(
            (s for s in saga.steps if s.name == comp['step_name']),
            None
        )
        
        if not step or not step.compensation:
            return
        
        # Get saga state for context
        saga_row = await self.db.fetch_one(
            "SELECT * FROM saga_instances WHERE saga_id = $1",
            comp['saga_id']
        )
        
        state = await self._reconstruct_state(saga_row)
        
        context = {
            "saga_id": state.saga_id,
            "input": state.input_data,
            "results": state.step_results,
            "step_result": state.step_results.get(comp['step_name'])
        }
        
        await step.compensation(context)
WorksheetSaga state at peak (traffic numbers are assumptions)
Value
Orders at peak (assumed) 500 a second
State writes per successful order saga (Chapter 6 code: 11 saves of the saga row (start, executing, 2 per step × 4 steps, completed) + 4 step records) 15 → 7,500 writes a second
Saga duration, start to finish (assumed) 2 s → 500 × 2 ≈ ~1,000 sagas in flight
Payment failures needing compensation (assumed 2%) ~10 compensations a second
Orchestrator dies, no restart: delay before recovery job resumes them 5 min cutoff + up to 30 s → ~1,000 orders stalled up to ~5.5 min

Conclusion: the saga state store takes about 15 writes for every order, so it must be sized as a write-heavy database in its own right. A crash leaves about a thousand orders stalled for minutes, which is why the 5-minute cutoff is a business decision, not just a tuning knob.

Chapter 13: Monitoring and Observability#

13.1 Saga Metrics#

pythonSagaMetricsCollector · 132 lines
# Saga Monitoring and Metrics

from prometheus_client import Counter, Histogram, Gauge
from dataclasses import dataclass
from typing import Dict
import time


# Prometheus metrics
saga_started = Counter(
    'saga_started_total',
    'Total sagas started',
    ['saga_type']
)

saga_completed = Counter(
    'saga_completed_total',
    'Total sagas completed successfully',
    ['saga_type']
)

saga_failed = Counter(
    'saga_failed_total',
    'Total sagas that failed',
    ['saga_type', 'failed_step']
)

saga_compensated = Counter(
    'saga_compensated_total',
    'Total sagas that were compensated',
    ['saga_type']
)

saga_duration = Histogram(
    'saga_duration_seconds',
    'Time to complete saga',
    ['saga_type', 'outcome'],
    buckets=[0.1, 0.5, 1, 5, 10, 30, 60, 300, 600]
)

saga_step_duration = Histogram(
    'saga_step_duration_seconds',
    'Time to complete saga step',
    ['saga_type', 'step_name'],
    buckets=[0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30]
)

saga_in_progress = Gauge(
    'saga_in_progress',
    'Number of sagas currently executing',
    ['saga_type']
)

compensation_retries = Counter(
    'saga_compensation_retries_total',
    'Total compensation retry attempts',
    ['saga_type', 'step_name']
)

saga_compensations_pending = Gauge(
    'saga_compensations_pending',
    'Compensations that failed and are waiting for a retry',
    ['saga_type']
)


class SagaMetricsCollector:
    """Collects and reports saga metrics."""
    
    def __init__(self):
        self.start_times: Dict[str, float] = {}
        self.step_start_times: Dict[str, float] = {}
    
    def on_saga_started(self, saga_id: str, saga_type: str):
        saga_started.labels(saga_type=saga_type).inc()
        saga_in_progress.labels(saga_type=saga_type).inc()
        self.start_times[saga_id] = time.time()
    
    def on_saga_completed(self, saga_id: str, saga_type: str):
        saga_completed.labels(saga_type=saga_type).inc()
        saga_in_progress.labels(saga_type=saga_type).dec()
        
        if saga_id in self.start_times:
            duration = time.time() - self.start_times[saga_id]
            saga_duration.labels(
                saga_type=saga_type,
                outcome='completed'
            ).observe(duration)
            del self.start_times[saga_id]
    
    def on_saga_failed(self, saga_id: str, saga_type: str, failed_step: str):
        saga_failed.labels(
            saga_type=saga_type,
            failed_step=failed_step
        ).inc()
        saga_in_progress.labels(saga_type=saga_type).dec()
        
        if saga_id in self.start_times:
            duration = time.time() - self.start_times[saga_id]
            saga_duration.labels(
                saga_type=saga_type,
                outcome='failed'
            ).observe(duration)
            del self.start_times[saga_id]
    
    def on_saga_compensated(self, saga_id: str, saga_type: str):
        saga_compensated.labels(saga_type=saga_type).inc()
    
    def on_step_started(self, saga_id: str, step_name: str):
        key = f"{saga_id}:{step_name}"
        self.step_start_times[key] = time.time()
    
    def on_step_completed(
        self,
        saga_id: str,
        saga_type: str,
        step_name: str
    ):
        key = f"{saga_id}:{step_name}"
        if key in self.step_start_times:
            duration = time.time() - self.step_start_times[key]
            saga_step_duration.labels(
                saga_type=saga_type,
                step_name=step_name
            ).observe(duration)
            del self.step_start_times[key]
    
    def on_compensation_retry(self, saga_type: str, step_name: str):
        compensation_retries.labels(
            saga_type=saga_type,
            step_name=step_name
        ).inc()

13.2 Saga Dashboard and Alerting#

python78 lines
# Alerting Rules (Prometheus/Alertmanager format)

ALERTING_RULES = """
groups:
  - name: saga_alerts
    rules:
      # High saga failure rate
      - alert: SagaHighFailureRate
        expr: |
          sum by (saga_type) (rate(saga_failed_total[5m]))
            / sum by (saga_type) (rate(saga_started_total[5m])) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High saga failure rate"
          description: "More than 10% of sagas are failing"
      
      # Saga taking too long
      - alert: SagaSlowExecution
        expr: |
          histogram_quantile(0.99, rate(saga_duration_seconds_bucket[5m])) > 60
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Sagas taking too long"
          description: "p99 saga duration is above 60 seconds"
      
      # Stuck compensations
      - alert: SagaStuckCompensations
        expr: |
          saga_compensations_pending > 10
        for: 15m
        labels:
          severity: critical
        annotations:
          summary: "Stuck saga compensations"
          description: "{{ $value }} compensations have been pending for 15+ minutes"
      
      # Compensation retry storm
      - alert: SagaCompensationRetryStorm
        expr: |
          rate(saga_compensation_retries_total[5m]) > 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High compensation retry rate"
          description: "Compensations are being retried frequently"
"""


# Dashboard query examples
DASHBOARD_QUERIES = {
    "saga_success_rate": """
        sum(rate(saga_completed_total[5m])) / 
        sum(rate(saga_started_total[5m]))
    """,
    
    "saga_p99_duration": """
        histogram_quantile(0.99, 
            sum(rate(saga_duration_seconds_bucket[5m])) by (le, saga_type)
        )
    """,
    
    "saga_failure_by_step": """
        sum(rate(saga_failed_total[1h])) by (saga_type, failed_step)
    """,
    
    "sagas_in_progress": """
        sum(saga_in_progress) by (saga_type)
    """,
    
    "compensation_pending": """
        count(saga_compensations_pending > 0) by (saga_type)
    """
}

Chapter 14: Testing Sagas#

14.1 Unit Testing Saga Steps#

pythonTestOrderSagaSteps · 101 lines
# Testing Saga Steps

import pytest
from unittest.mock import AsyncMock, MagicMock


class TestOrderSagaSteps:
    """Unit tests for order saga steps."""
    
    @pytest.fixture
    def inventory_service(self):
        service = AsyncMock()
        service.reserve.return_value = "reservation-123"
        service.release.return_value = True
        return service
    
    @pytest.fixture
    def payment_service(self):
        service = AsyncMock()
        service.charge.return_value = "payment-456"
        service.refund.return_value = True
        return service
    
    @pytest.fixture
    def saga_steps(self, inventory_service, payment_service):
        return OrderSagaSteps(
            inventory_service=inventory_service,
            payment_service=payment_service,
            shipping_service=AsyncMock(),
            notification_service=AsyncMock()
        )
    
    @pytest.mark.asyncio
    async def test_reserve_inventory_success(self, saga_steps, inventory_service):
        """Test successful inventory reservation."""
        context = {
            'saga_id': 'saga-123',
            'input': {
                'items': [{'product_id': 'prod-1', 'quantity': 2}]
            },
            'results': {}
        }
        
        result = await saga_steps.reserve_inventory(context)
        
        assert result == {'reservation_id': 'reservation-123'}
        inventory_service.reserve.assert_called_once_with(
            order_id='saga-123',
            items=[{'product_id': 'prod-1', 'quantity': 2}]
        )
    
    @pytest.mark.asyncio
    async def test_release_inventory_compensation(self, saga_steps, inventory_service):
        """Test inventory release compensation."""
        context = {
            'saga_id': 'saga-123',
            'input': {},
            'results': {
                'reserve_inventory': {'reservation_id': 'reservation-123'}
            }
        }
        
        await saga_steps.release_inventory(context)
        
        inventory_service.release.assert_called_once_with('reservation-123')
    
    @pytest.mark.asyncio
    async def test_release_inventory_idempotent(self, saga_steps, inventory_service):
        """Test compensation is idempotent when no reservation exists."""
        context = {
            'saga_id': 'saga-123',
            'input': {},
            'results': {}  # No reservation result
        }
        
        # Should not raise, should be no-op
        await saga_steps.release_inventory(context)
        
        inventory_service.release.assert_not_called()
    
    @pytest.mark.asyncio
    async def test_charge_payment_uses_idempotency_key(
        self, 
        saga_steps, 
        payment_service
    ):
        """Test payment uses idempotency key."""
        context = {
            'saga_id': 'saga-123',
            'input': {
                'customer_id': 'cust-1',
                'total': 99.99
            },
            'results': {}
        }
        
        await saga_steps.charge_payment(context)
        
        payment_service.charge.assert_called_once()
        call_kwargs = payment_service.charge.call_args.kwargs
        assert call_kwargs['idempotency_key'] == 'saga-saga-123'

14.2 Integration Testing Sagas#

pythonTestOrderSagaIntegration · 139 lines
# Integration Testing for Sagas

import pytest
from testcontainers.postgres import PostgresContainer


class TestOrderSagaIntegration:
    """Integration tests for complete order saga."""
    
    @pytest.fixture(scope="class")
    def postgres(self):
        with PostgresContainer("postgres:14") as pg:
            yield pg
    
    @pytest.fixture
    async def db(self, postgres):
        conn = await asyncpg.connect(postgres.get_connection_url())
        await self._setup_schema(conn)
        yield conn
        await conn.close()
    
    @pytest.fixture
    def orchestrator(self, db):
        steps = OrderSagaSteps(
            inventory_service=RealInventoryService(db),
            payment_service=MockPaymentService(),
            shipping_service=MockShippingService(),
            notification_service=MockNotificationService()
        )
        
        saga = create_order_saga(steps)
        return SagaOrchestrator(db, {"OrderSaga": saga})
    
    @pytest.mark.asyncio
    async def test_successful_order_saga(self, orchestrator, db):
        """Test complete successful saga execution."""
        saga_id = await orchestrator.start_saga(
            "OrderSaga",
            {
                "customer_id": "cust-123",
                "items": [{"product_id": "prod-1", "quantity": 1}],
                "total": 99.99,
                "shipping_address": "123 Main St"
            }
        )
        
        # Wait for saga to complete
        await asyncio.sleep(2)
        
        # Check saga completed
        state = await db.fetch_one(
            "SELECT status FROM saga_instances WHERE saga_id = $1",
            saga_id
        )
        assert state['status'] == 'completed'
        
        # Check all steps completed
        steps = await db.fetch(
            """
            SELECT step_name, status FROM saga_step_executions 
            WHERE saga_id = $1 ORDER BY step_index
            """,
            saga_id
        )
        assert all(s['status'] == 'completed' for s in steps)
    
    @pytest.mark.asyncio
    async def test_saga_compensation_on_payment_failure(
        self, 
        orchestrator, 
        db
    ):
        """Test saga compensates when payment fails."""
        # Configure payment to fail
        orchestrator.sagas["OrderSaga"].steps[1].action = AsyncMock(
            side_effect=PaymentError("Card declined")
        )
        
        saga_id = await orchestrator.start_saga(
            "OrderSaga",
            {
                "customer_id": "cust-123",
                "items": [{"product_id": "prod-1", "quantity": 1}],
                "total": 99.99
            }
        )
        
        await asyncio.sleep(2)
        
        # Check saga was compensated
        state = await db.fetch_one(
            "SELECT status FROM saga_instances WHERE saga_id = $1",
            saga_id
        )
        assert state['status'] == 'compensated'
        
        # Check inventory was released
        compensation = await db.fetch_one(
            """
            SELECT status FROM saga_compensations 
            WHERE saga_id = $1 AND step_name = 'reserve_inventory'
            """,
            saga_id
        )
        assert compensation['status'] == 'completed'
    
    @pytest.mark.asyncio
    async def test_saga_recovery_after_crash(self, orchestrator, db):
        """Test saga recovers after simulated crash."""
        saga_id = await orchestrator.start_saga(
            "OrderSaga",
            {"customer_id": "cust-123", "items": [], "total": 50.00}
        )
        
        # Simulate crash by stopping orchestrator mid-execution
        await asyncio.sleep(0.1)
        
        # Mark saga as stuck
        await db.execute(
            """
            UPDATE saga_instances 
            SET updated_at = NOW() - INTERVAL '10 minutes'
            WHERE saga_id = $1
            """,
            saga_id
        )
        
        # Run recovery
        recovery_job = SagaRecoveryJob(db, orchestrator)
        await recovery_job._recover_stuck_sagas()
        
        await asyncio.sleep(2)
        
        # Verify saga eventually completes
        state = await db.fetch_one(
            "SELECT status FROM saga_instances WHERE saga_id = $1",
            saga_id
        )
        assert state['status'] in ('completed', 'compensated')
Quick check3 questions · +5 XP each

Saga persistence, monitoring and testing

  1. Q1/3 The recovery job only picks up sagas in started or executing whose updated_at is more than 5 minutes old. Why the age filter?

    Answer & explanation

    Answer: B

    Without the age filter the job would resume sagas that are mid-flight and run them twice in parallel. Every step saves state, so a saga with no update for 5 minutes is a strong signal its orchestrator died.

  2. Q2/3 A compensation keeps failing. The recovery job backs off up to 5 minutes between tries and logs a critical alert after 10 retries. What does that design express?

    Answer & explanation

    Answer: D

    Giving up on a refund leaves the customer wrongly charged, so retries continue with capped backoff and an operator is pulled in. Marking it done after N tries would silently leave the system inconsistent.

  3. Q3/3 Which alert most directly signals that customers may be stuck charged without a refund?

    Answer & explanation

    Answer: C

    Pending compensations mean undo work is not finishing, which is why it is the critical alert. A high failure rate is only a warning: failed sagas that compensate cleanly leave the system consistent.

Further Reading#

Papers#

  • Book

    "Sagas" by Hector Garcia-Molina and Kenneth Salem (1987) — Original paper

  • Book

    "Life Beyond Distributed Transactions" by Pat Helland

Books#

  • Book

    "Microservices Patterns" by Chris Richardson — Chapter 4 covers sagas

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann — Distributed transactions

Engineering Blogs#

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Distributed Transactions — The Saga Pattern
Finish Day 2Stages clear as you read them (0/5 so far). Skipping ahead doesn't count: marking complete unlocks after ~9 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

PrincetonPaper · ~30 min

Sagas

Hector Garcia-Molina, Kenneth Salem · 1987
What
The 1987 paper that introduced sagas for long-lived transactions.
Key idea
Split a long transaction into steps that each commit locally, each paired with a compensating step that semantically undoes it if a later step fails.
Why here
The original definition behind this lesson: sagas trade isolation for availability and need compensations designed up front.
taught here: Compensating transactions
microservices.ioDocs · ~6 min

Pattern: Saga

Chris Richardson
What
The pattern reference page for sagas in microservices.
Key idea
Choreography lets each service react to the previous one's events; orchestration has a coordinator tell services what to do, and both lack isolation.
Why here
A compact summary of this lesson's choreography vs orchestration chapter, with the isolation countermeasures it raises.
taught here: Choreography vs orchestration
NetflixBlog post · ~7 min

Netflix Conductor: A microservices orchestrator

2016
What
Netflix introduces Conductor, the orchestration engine for its content-processing workflows.
Key idea
Peer-to-peer choreography became hard to follow, so workflows are defined as JSON blueprints and a central engine schedules tasks on worker services.
Why here
Explains why a team moves from choreography to orchestration, the decision this lesson asks you to justify.
taught here: Orchestration

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…