Distributed Sagas & Compensating Transactions: Orchestrating Multi-Service Workflows Without Two-Phase Commit (2PC)

Design resilient distributed transactions across microservices. Compare Choreographed vs. Orchestrated Sagas, implement idempotent compensating actions, and ensure eventual consistency without the availability bottlenecks of Two-Phase Commit (2PC).

The Death of Two-Phase Commit in High-Throughput Microservices

In classical monolithic relational architectures, ensuring atomic consistency across data operations is solved at the engine level: a single database transaction guarantees ACID (Atomicity, Consistency, Isolation, Durability) properties through write-ahead logging (WAL) and row-level locks. However, when splitting a monolith into independently deployable microservices—each with its own private database—spanning a business transaction across multiple services introduces fundamental distributed systems challenges.

Historically, enterprise architectures attempted to solve this using Two-Phase Commit (2PC) or the X/Open XA standard. 2PC works via a centralized coordinator that executes two phases:

  1. Prepare Phase: The coordinator instructs all participants to prepare to commit and acquire all required row/table locks. Participants reply with VOTE_COMMIT or VOTE_ABORT.
  2. Commit Phase: If all nodes vote to commit, the coordinator broadcasts a GLOBAL_COMMIT directive; otherwise, it sends GLOBAL_ABORT.

While conceptually elegant, 2PC fails catastrophically in modern cloud and microservice environments due to three fatal architectural flaws:

  • Blocking Locks and Latency Amplification: Every participating service must hold exclusive database locks from the start of Phase 1 until the coordinator's Phase 2 broadcast completes over the network. If one service experiences a garbage collection pause or network blip, all database locks across all services remain held, causing thread pool exhaustion and catastrophic cascading failure.
  • CAP Theorem Trade-Off: 2PC chooses CP (Consistency/Partition Tolerance) over Availability. In an unreliable network, any network partition between the coordinator and a participant halts the entire system.
  • Single Point of Coordinator Failure: If the transaction coordinator crashes after participants have voted yes but before issuing the commit decision, participants are left in an indeterminate "in-doubt" state, unable to release locks or proceed autonomously.

The Saga Pattern: Local Transactions and Compensating Actions

First proposed by Hector Garcia-Molina and Kenneth Salem in 1987, the Saga Pattern replaces a single distributed ACID transaction with a sequence of local transactions: $T_1, T_2, \dots, T_n$. Each local transaction updates the database within a single service boundary and publishes an event or message triggering the next step.

Because local transactions commit independently, intermediate states are visible to concurrent transactions (relaxing Isolation for Eventual Consistency). If any step $T_i$ fails (e.g., payment rejected, insufficient inventory), the system executes a sequence of compensating transactions in reverse order: $C_{i-1}, \dots, C_1$ to undo earlier side effects.

Dimension Two-Phase Commit (2PC) Saga Pattern
Consistency Model Strict Immediate Consistency (ACID) Eventual Consistency (BASE)
Lock Holding Time Entire duration of distributed transaction (seconds) Duration of single local transaction (milliseconds)
Availability & Fault Tolerance Low (Blocks on any partition or slow node) High (Non-blocking, resilient to transient outages)
Scalability Limit Low (~100 to 500 tx/sec before lock contention) Massive horizontal scalability (100k+ tx/sec)
Rollback Mechanism Automatic WAL abort in database engine Explicit application-level compensating actions ($C_k$)

Choreography vs. Orchestration: Selecting the Right Topology

There are two primary architectural styles for implementing Sagas: Event Choreography and Command Orchestration.

1. Choreography (Event-Driven Decentralization)

In a choreographed saga, there is no centralized coordinator. Each service listens for domain events published by preceding services, executes its local transaction, and publishes new domain events:

Order Service (Created) ──> Payment Service (Charged) ──> Inventory Service (Failed: Out of Stock)
                                    │
                                    └──<── Refund Event Published ──<──┘

Trade-offs: Excellent for simple 2–3 step workflows where loose coupling is paramount. However, at 5+ services, choreography degenerates into "event pinball": cyclic dependencies emerge, testing requires running the entire distributed cluster, and discovering the current state of an order requires stitching distributed tracing spans across Kafka topics.

2. Orchestration (Centralized State Machine)

In an orchestrated saga, a dedicated service or module acts as the Saga Orchestrator. It manages a persistent state machine, sends synchronous command requests to downstream participant services, evaluates results, and dispatches compensating commands if any service fails:

                     ┌─────────────────────────────┐
                     │      Saga Orchestrator      │
                     │  (Persistent State Machine) │
                     └──────┬───────┬───────┬──────┘
                            │       │       │
              1. Reserve    │       │       │ 3. Dispatch
                 Seat       │       │       │    Ticket
                     ▼      │       │ 2. Charge  ▼
          ┌─────────────┐   │       │    Card  ┌─────────────┐
          │ Booking Svc │   │       ▼          │ Ticket Svc  │
          └─────────────┘   │  ┌────────────┐  └─────────────┘
                            └──┤Payment Svc │
                               └────────────┘
Evaluation Criteria Choreographed Saga Orchestrated Saga (Recommended)
Service Coupling Loose: Services depend only on message topics Moderate: Orchestrator knows participant endpoints
Visibility & Debugging Difficult: State distributed across message logs Trivial: Single query to orchestrator state store
Cyclic Dependencies High risk as workflows expand Zero: Orchestrator invokes downstream linearly
Transaction Complexity Best suited for simple 2-4 step flows Ideal for enterprise workflows with 5-20+ steps

Idempotency Ledgers: Guaranteeing Exactly-Once Execution

Because network calls can fail, time out, or be retried by the orchestrator, every forward transaction step ($T_k$) and compensating transaction ($C_k$) must be strictly idempotent. Executing a refund or reservation twice must produce the exact same outcome as executing it once.

The standard architectural pattern is to implement an Idempotency Ledger in PostgreSQL backed by a unique constraint on (idempotency_key, action):

-- Participant Database Schema: Idempotency Ledger
CREATE TABLE service_idempotency_ledger (
    idempotency_key VARCHAR(128) NOT NULL,
    action_name VARCHAR(64) NOT NULL,
    response_payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (idempotency_key, action_name)
);

CREATE INDEX idx_ledger_created_at ON service_idempotency_ledger(created_at);

When the service receives a command, it verifies whether the idempotency key has already completed within an atomic database transaction:

# participant_service.py: Atomic Idempotent Action
import json
from django.db import transaction
from .models import Account, LedgerEntry

def process_charge(idempotency_key: str, account_id: str, amount_cents: int) -> dict:
    with transaction.atomic():
        # Check if already processed
        existing = LedgerEntry.objects.filter(
            idempotency_key=idempotency_key,
            action_name="CHARGE_ACCOUNT"
        ).first()
        
        if existing:
            # Return cached response immediately without repeating side-effects
            return existing.response_payload

        # Execute business logic
        account = Account.objects.select_for_update().get(id=account_id)
        if account.balance_cents < amount_cents:
            raise InsufficientFundsException("Balance too low")

        account.balance_cents -= amount_cents
        account.save(update_fields=["balance_cents"])

        result_data = {
            "status": "SUCCESS",
            "account_id": account_id,
            "deducted_cents": amount_cents,
            "new_balance": account.balance_cents
        }

        # Record into ledger within the SAME database transaction
        LedgerEntry.objects.create(
            idempotency_key=idempotency_key,
            action_name="CHARGE_ACCOUNT",
            response_payload=result_data
        )

        return result_data

Production Python Implementation: Async Saga Orchestrator

The following production-ready Python orchestrator demonstrates an asynchronous state-machine engine. It executes sequential forward steps, records state checkpoints, and automatically executes reverse compensating transactions when downstream failures occur.

# saga_orchestrator.py
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Coroutine, Dict, List, Optional

logger = logging.getLogger("saga.orchestrator")


class SagaStatus(str, Enum):
    PENDING = "PENDING"
    EXECUTING = "EXECUTING"
    COMPLETED = "COMPLETED"
    COMPENSATING = "COMPENSATING"
    COMPENSATED = "COMPENSATED"
    FAILED_CRITICALLY = "FAILED_CRITICALLY"


@dataclass
class SagaStep:
    name: str
    action: Callable[[Dict[str, Any]], Coroutine[Any, Any, Dict[str, Any]]]
    compensate: Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]
    retry_count: int = 3
    retry_delay_seconds: float = 0.5


class SagaExecutionError(Exception):
    def __init__(self, step_name: str, cause: Exception):
        super().__init__(f"Saga failed at step '{step_name}': {cause}")
        self.step_name = step_name
        self.cause = cause


class SagaOrchestrator:
    def __init__(self, saga_id: Optional[str] = None):
        self.saga_id = saga_id or str(uuid.uuid4())
        self.steps: List[SagaStep] = []
        self.status = SagaStatus.PENDING
        self.context: Dict[str, Any] = {}
        self.executed_steps: List[SagaStep] = []

    def add_step(self, step: SagaStep) -> "SagaOrchestrator":
        self.steps.append(step)
        return self

    async def execute(self, initial_payload: Dict[str, Any]) -> Dict[str, Any]:
        """
        Executes forward transactions sequentially.
        If any step fails, initiates reverse compensating transactions.
        """
        self.context = initial_payload.copy()
        self.context["saga_id"] = self.saga_id
        self.status = SagaStatus.EXECUTING
        logger.info(f"[Saga {self.saga_id}] Initiating execution with {len(self.steps)} steps.")

        for step in self.steps:
            logger.info(f"[Saga {self.saga_id}] Executing forward step: {step.name}")
            success = False
            last_exception: Optional[Exception] = None

            for attempt in range(1, step.retry_count + 1):
                try:
                    # Provide saga_id and step name for idempotency key derivation
                    self.context["idempotency_key"] = f"{self.saga_id}:{step.name}"
                    result = await step.action(self.context)
                    
                    if result:
                        self.context.update(result)
                        
                    self.executed_steps.append(step)
                    success = True
                    break
                except Exception as exc:
                    last_exception = exc
                    logger.warning(
                        f"[Saga {self.saga_id}] Step '{step.name}' attempt {attempt}/{step.retry_count} failed: {exc}"
                    )
                    if attempt < step.retry_count:
                        await asyncio.sleep(step.retry_delay_seconds * (2 ** (attempt - 1)))

            if not success:
                logger.error(
                    f"[Saga {self.saga_id}] Step '{step.name}' failed permanently. Initiating compensations."
                )
                self.status = SagaStatus.COMPENSATING
                await self._compensate()
                raise SagaExecutionError(step.name, last_exception)

        self.status = SagaStatus.COMPLETED
        logger.info(f"[Saga {self.saga_id}] Successfully finished all steps.")
        return self.context

    async def _compensate(self):
        """
        Executes compensating actions in reverse order of successfully executed steps.
        Compensating actions MUST be retried until successful.
        """
        for step in reversed(self.executed_steps):
            logger.info(f"[Saga {self.saga_id}] Compensating step: {step.name}")
            compensated = False
            
            # Compensating actions must be retried aggressively
            for attempt in range(1, 6):
                try:
                    self.context["idempotency_key"] = f"{self.saga_id}:compensate:{step.name}"
                    await step.compensate(self.context)
                    compensated = True
                    break
                except Exception as exc:
                    logger.critical(
                        f"[Saga {self.saga_id}] Compensation for '{step.name}' failed on attempt {attempt}: {exc}"
                    )
                    await asyncio.sleep(1.0 * attempt)

            if not compensated:
                # If compensation fails after retries, escalate to manual intervention queue
                self.status = SagaStatus.FAILED_CRITICALLY
                logger.critical(
                    f"[Saga {self.saga_id}] CRITICAL: Step '{step.name}' failed compensation! Poison pill queued."
                )
                return

        self.status = SagaStatus.COMPENSATED
        logger.info(f"[Saga {self.saga_id}] Compensation sequence completed successfully.")

Complete E-Commerce Order Checkout Workflow

To see the orchestrator in action, consider a 3-step checkout flow: reserving warehouse inventory, deducting customer credits, and dispatching shipping logistics:

# test_checkout_workflow.py
import asyncio
from saga_orchestrator import SagaOrchestrator, SagaStep

# Downstream Service Mocks
async def reserve_inventory(ctx: dict) -> dict:
    item_id = ctx["item_id"]
    qty = ctx["quantity"]
    print(f"--> [Inventory Service] Reserved {qty} of item {item_id}")
    return {"reservation_id": f"res_9981_{item_id}"}

async def cancel_inventory(ctx: dict):
    print(f"<-- [Inventory Service] Released reservation: {ctx.get('reservation_id')}")

async def charge_customer(ctx: dict) -> dict:
    amount = ctx["total_price"]
    print(f"--> [Billing Service] Charged ${amount:.2f}")
    return {"payment_tx_id": "tx_stripe_8820"}

async def refund_customer(ctx: dict):
    print(f"<-- [Billing Service] Refunded payment: {ctx.get('payment_tx_id')}")

async def create_shipment(ctx: dict) -> dict:
    # Simulate a third-party courier outage
    raise ConnectionResetError("Courier API Gateway Timeout (HTTP 504)")

async def cancel_shipment(ctx: dict):
    print(f"<-- [Shipping Service] Cancelled shipment consignment.")

async def main():
    saga = SagaOrchestrator()
    
    saga.add_step(SagaStep("reserve_inventory", reserve_inventory, cancel_inventory))
    saga.add_step(SagaStep("charge_customer", charge_customer, refund_customer))
    saga.add_step(SagaStep("create_shipment", create_shipment, cancel_shipment))

    order_payload = {
        "user_id": "usr_404",
        "item_id": "sku_macbook_pro_m4",
        "quantity": 1,
        "total_price": 2499.00
    }

    try:
        await saga.execute(order_payload)
    except Exception as exc:
        print(f"\nFinal Order Status: FAILED ({exc})")
        print(f"Saga State Machine Final Status: {saga.status.value}")

if __name__ == "__main__":
    asyncio.run(main())

Execution Output:

[Saga 3c49e48c] Initiating execution with 3 steps.
[Saga 3c49e48c] Executing forward step: reserve_inventory
--> [Inventory Service] Reserved 1 of item sku_macbook_pro_m4
[Saga 3c49e48c] Executing forward step: charge_customer
--> [Billing Service] Charged $2499.00
[Saga 3c49e48c] Executing forward step: create_shipment
[Saga 3c49e48c] Step 'create_shipment' attempt 1/3 failed: Courier API Gateway Timeout (HTTP 504)
[Saga 3c49e48c] Step 'create_shipment' attempt 2/3 failed: Courier API Gateway Timeout (HTTP 504)
[Saga 3c49e48c] Step 'create_shipment' attempt 3/3 failed: Courier API Gateway Timeout (HTTP 504)
[Saga 3c49e48c] Step 'create_shipment' failed permanently. Initiating compensations.
[Saga 3c49e48c] Compensating step: charge_customer
<-- [Billing Service] Refunded payment: tx_stripe_8820
[Saga 3c49e48c] Compensating step: reserve_inventory
<-- [Inventory Service] Released reservation: res_9981_sku_macbook_pro_m4
[Saga 3c49e48c] Compensation sequence completed successfully.

Final Order Status: FAILED (Saga failed at step 'create_shipment': Courier API Gateway Timeout (HTTP 504))
Saga State Machine Final Status: COMPENSATED

Handling Semantic Anomalies Without Database Locks

Because the Saga pattern does not hold global database locks, intermediate states are visible to concurrent requests. This introduces three classical ACID-isolation anomalies that you must mitigate at the application level:

  1. Lost Updates: One saga overwrites an uncommitted value produced by another saga.
    Mitigation: Use pessimistic row-level locking (SELECT FOR UPDATE) within each local transaction, or enforce optimistic concurrency control via integer version counters (WHERE version = @current_version).
  2. Dirty Reads: Another user or query reads data updated by an ongoing saga that subsequently aborts and compensates.
    Mitigation: Semantic Lock Pattern: Flag the resource as in-flight (e.g., status = "PENDING_VERIFICATION"). Other services treat rows with this status as locked and either queue their requests or display a non-committal UI state.
  3. Non-Repeatable Reads: A service reads a record, and before the saga completes, a separate external transaction alters the record.
    Mitigation: Structure steps so that all read-dependent validations happen early in pivot transactions before committing non-compensatable actions.

Architectural Summary & Production Checklist

Requirement Production Standard Verification Mechanism
Idempotency Every forward and compensating RPC must accept an idempotency key. Unique PostgreSQL database ledger constraints.
Pivot Transactions Place the point of no return (e.g. non-reversible physical action) as the last forward step. Steps after the pivot must be guaranteed to succeed via retry loops.
Transactional Outbox Persist saga status transitions and outbound messages atomically in the orchestrator DB. Debezium CDC or background polling publisher reading outbox table.
Dead-Letter Queue & Alerting Compensating steps that fail all retries must trigger high-priority alerts. PagerDuty / Opsgenie escalation for manual reconciliation.
// High-Throughput Engineering • Systems Architecture Consulting

Scaling Python & Django APIs or Resolving Concurrency Bottlenecks?

We partner with engineering founders and tech leads to architect resilient distributed systems, optimize async worker pools, design scalable databases, and eliminate production latency spikes.

All Insights
Chat on WhatsApp