Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Cross-System Transaction Management: Implementing Distributed Sagas and Compensating Transactions for Multi-Sink Data Pipelines

Cross-System Transaction Management: Implementing Distributed Sagas and Compensating Transactions for Multi-Sink Data Pipelines

Data Engineering🔥 Expert24 min readAug 14, 2026Updated Aug 14, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Why Distributed Transactions Don't Actually Solve This
  • Understanding the Saga Pattern
  • Modeling the Saga State Machine
  • Persisting Saga State
  • Implementing the Saga Orchestrator
  • Implementing Compensating Transactions
  • Handling the Hardest Failure Mode: Non-Idempotent External Systems
  • Designing for Observability
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Cross-System Transaction Management in Data Pipelines: Implementing Distributed Sagas and Compensating Transactions for Multi-Sink Writes

    Introduction

    Picture this: your e-commerce data pipeline successfully writes a completed order to your PostgreSQL operational database, updates the inventory count in DynamoDB, and then fails spectacularly while attempting to publish the fulfillment event to Kafka. Now your order exists in two systems but the warehouse management software never received the trigger to pick the product off the shelf. Your customer gets a confirmation email, but their package never ships. The data is inconsistent across your systems, and the only way you know something went wrong is when customer support starts getting angry calls three days later.

    This is the central problem of multi-sink data pipelines: you're writing to multiple heterogeneous systems, and no single transaction coordinator speaks all their languages simultaneously. Traditional ACID transactions work beautifully within a single database because the system owns the log, the locks, and the commit protocol. The moment you step across system boundaries — Postgres to Kafka to S3 to an external API — you've exited the safety of atomic guarantees. You're now in distributed systems territory, where partial failures aren't edge cases; they're scheduled events.

    By the end of this lesson, you'll understand how to architect pipelines that handle partial failures gracefully using the Saga pattern, implement compensating transactions that undo work already committed to upstream systems, and build the state machine machinery that makes all of this observable and debuggable in production. We'll work through a realistic order processing pipeline in Python that writes to PostgreSQL, publishes to Kafka, and updates a Redis cache — and we'll make it resilient to failures at every step.

    What you'll learn:

    • Why distributed transactions (2PC, XA) fail in practice and why the Saga pattern exists as an alternative
    • How to model a multi-sink pipeline as a Saga with explicit steps, rollback handlers, and failure semantics
    • How to implement compensating transactions that are idempotent, ordered, and safe to retry
    • How to persist Saga state so your pipeline can resume or roll back across process restarts
    • How to design for observability: tracking which compensations ran, which failed, and why

    Prerequisites

    You should be comfortable with:

    • Python async programming (asyncio, async/await)
    • Basic SQL and the concept of database transactions
    • A working understanding of Kafka producers and consumers (or any message broker)
    • Familiarity with Redis as a key-value store
    • General data pipeline concepts: sources, sinks, transformations

    You don't need prior experience with distributed systems theory, though a passing familiarity with CAP theorem won't hurt.


    Why Distributed Transactions Don't Actually Solve This

    Before building the solution, you need to internalize why the obvious solution doesn't work. When engineers first encounter this problem, the instinct is usually: "Can't we just use a distributed transaction? XA protocol? Two-phase commit?"

    Two-phase commit (2PC) works by electing a coordinator that first sends a "prepare" message to all participants, waits for acknowledgment that each participant has durably staged the write, and then sends a global commit or abort. It genuinely provides atomicity across systems — in theory.

    In practice, 2PC has several catastrophic failure modes. First, it requires all participants to implement the XA interface, and almost nothing in a modern data stack does. Kafka doesn't support XA. Most REST APIs definitely don't. DynamoDB doesn't. Redis doesn't. You'd need every system in your pipeline to implement a two-phase protocol with durable prepare logging, which essentially means reimplementing a distributed database inside every system you touch.

    Second, 2PC is a blocking protocol. If the coordinator crashes after sending "prepare" but before sending "commit," every participant holds locks indefinitely waiting for a commit signal that may never come. Your PostgreSQL tables are locked, your Kafka producer is stalled, and your pipeline is dead until a human intervenes or the coordinator recovers with its state intact. This is called the "coordinator failure problem," and it transforms a transient hardware failure into a system-wide outage.

    Third, even when 2PC works correctly, it introduces significant latency because every write must complete two round-trips plus synchronous durability guarantees from all participants simultaneously. For a pipeline writing to five systems, you're waiting for the slowest system on every single transaction.

    The alternative is to accept that perfect atomicity across heterogeneous systems isn't achievable, and instead design for eventual consistency with explicit compensation. This is the insight behind the Saga pattern.


    Understanding the Saga Pattern

    A Saga is a sequence of local transactions where each step has a corresponding compensating transaction. If step N fails, the saga executes the compensating transactions for steps N-1, N-2, ..., 1 in reverse order, returning the system to a consistent state.

    The critical insight is that compensating transactions are not rollbacks in the traditional database sense. They are new, forward-moving transactions that logically undo the effect of an earlier step. If you debited a customer's account as step 2 and need to "undo" that, you don't reach back into history and delete the debit row — you credit the account in a new transaction. The ledger now has both entries, and the net effect is zero. This is crucial because the debit may have already been read by other processes, reported in dashboards, or replicated to read replicas. You can't pretend it didn't happen.

    There are two main coordination strategies for Sagas:

    Choreography-based Sagas have no central coordinator. Each service listens for events, performs its local transaction, and publishes the next event. Compensation happens by publishing "failure" events that each service handles by running its own compensating logic. This works well for loosely coupled microservices but becomes very hard to reason about as the number of steps grows — the "saga" exists only implicitly in the choreography of events, making it extremely difficult to debug or audit.

    Orchestration-based Sagas have a central orchestrator (your pipeline code) that explicitly calls each participant, tracks state, and drives compensation when needed. The entire saga's state is visible in one place. For data pipelines, orchestration is almost always the right choice because you control all the participants and observability is paramount.

    We'll implement an orchestration-based saga.


    Modeling the Saga State Machine

    A saga is fundamentally a state machine. Before writing any pipeline code, you need to explicitly model the states a saga instance can be in.

    For our order processing pipeline, the saga steps are:

    1. Write the order record to PostgreSQL (orders table)
    2. Publish an order.created event to Kafka
    3. Decrement inventory count in Redis
    4. Write order details to an analytics sink (a second PostgreSQL schema or data warehouse)

    The compensating transactions are:

    1. Mark the order as CANCELLED in PostgreSQL (we don't delete — see why below)
    2. Publish an order.cancelled event to Kafka
    3. Increment the inventory count back in Redis
    4. Delete or mark the analytics record as void

    Here's the state machine:

    PENDING → STEP_1_COMPLETE → STEP_2_COMPLETE → STEP_3_COMPLETE → COMPLETED
                                                                         ↑ 
    FAILED_STEP_1 → (no compensation needed, nothing succeeded)
    FAILED_STEP_2 → COMPENSATING_STEP_1 → COMPENSATED
    FAILED_STEP_3 → COMPENSATING_STEP_2 → COMPENSATING_STEP_1 → COMPENSATED
    FAILED_STEP_4 → COMPENSATING_STEP_3 → COMPENSATING_STEP_2 → COMPENSATING_STEP_1 → COMPENSATED
    

    Notice that compensation always runs in reverse and compensation itself can fail (which we'll handle). Let's encode this in Python:

    from enum import Enum, auto
    from dataclasses import dataclass, field
    from datetime import datetime
    from typing import Optional, Dict, Any
    import uuid
    
    class SagaStatus(Enum):
        PENDING = "PENDING"
        STEP_1_COMPLETE = "STEP_1_COMPLETE"   # Order written to Postgres
        STEP_2_COMPLETE = "STEP_2_COMPLETE"   # Kafka event published
        STEP_3_COMPLETE = "STEP_3_COMPLETE"   # Redis inventory decremented
        COMPLETED = "COMPLETED"               # Analytics write successful
        FAILED = "FAILED"                     # Saga failed, compensation needed
        COMPENSATING = "COMPENSATING"         # Compensation in progress
        COMPENSATED = "COMPENSATED"           # Fully rolled back
        COMPENSATION_FAILED = "COMPENSATION_FAILED"  # Needs manual intervention
    
    @dataclass
    class SagaInstance:
        saga_id: str
        order_data: Dict[str, Any]
        status: SagaStatus
        failed_at_step: Optional[int] = None
        failure_reason: Optional[str] = None
        compensation_step: Optional[int] = None  # which step we're compensating
        created_at: datetime = field(default_factory=datetime.utcnow)
        updated_at: datetime = field(default_factory=datetime.utcnow)
        retry_count: int = 0
        
        def to_dict(self) -> Dict[str, Any]:
            return {
                "saga_id": self.saga_id,
                "order_data": self.order_data,
                "status": self.status.value,
                "failed_at_step": self.failed_at_step,
                "failure_reason": self.failure_reason,
                "compensation_step": self.compensation_step,
                "created_at": self.created_at.isoformat(),
                "updated_at": self.updated_at.isoformat(),
                "retry_count": self.retry_count,
            }
        
        @classmethod
        def from_dict(cls, data: Dict[str, Any]) -> "SagaInstance":
            data["status"] = SagaStatus(data["status"])
            data["created_at"] = datetime.fromisoformat(data["created_at"])
            data["updated_at"] = datetime.fromisoformat(data["updated_at"])
            return cls(**data)
    

    Why not delete failed records? It's tempting to delete the PostgreSQL row as the compensating action, but deletion destroys audit trails. In financial or order systems, you want every attempt recorded. Use a status column with values like ACTIVE, CANCELLED, COMPENSATED. This also makes compensating idempotent — you can mark a row CANCELLED twice safely, whereas a DELETE that runs twice silently succeeds the second time even if a new row with the same ID was inserted by a retry.


    Persisting Saga State

    The saga state machine is only useful if it survives process crashes. If your pipeline worker dies mid-saga, you need to resume from the last known good state — either retry the failed step or run compensation.

    We'll persist saga state in a dedicated PostgreSQL table. Why Postgres and not Redis or a file? Because saga state is critical coordination data that needs:

    • Durability across crashes (Redis AOF is async by default)
    • Queryability (you want to find all sagas in COMPENSATION_FAILED state)
    • Atomic state transitions (you can use UPDATE ... WHERE status = 'COMPENSATING' to prevent races)
    CREATE TABLE saga_log (
        saga_id         UUID PRIMARY KEY,
        order_data      JSONB NOT NULL,
        status          TEXT NOT NULL,
        failed_at_step  INTEGER,
        failure_reason  TEXT,
        compensation_step INTEGER,
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        retry_count     INTEGER NOT NULL DEFAULT 0
    );
    
    CREATE INDEX idx_saga_log_status ON saga_log (status);
    CREATE INDEX idx_saga_log_updated_at ON saga_log (updated_at);
    

    Now let's build the persistence layer:

    import asyncpg
    import json
    from typing import Optional
    
    class SagaRepository:
        def __init__(self, pool: asyncpg.Pool):
            self.pool = pool
        
        async def create(self, saga: SagaInstance) -> None:
            async with self.pool.acquire() as conn:
                await conn.execute("""
                    INSERT INTO saga_log 
                        (saga_id, order_data, status, failed_at_step, failure_reason,
                         compensation_step, created_at, updated_at, retry_count)
                    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
                """,
                    saga.saga_id,
                    json.dumps(saga.order_data),
                    saga.status.value,
                    saga.failed_at_step,
                    saga.failure_reason,
                    saga.compensation_step,
                    saga.created_at,
                    saga.updated_at,
                    saga.retry_count,
                )
        
        async def update_status(
            self, 
            saga_id: str, 
            new_status: SagaStatus,
            failed_at_step: Optional[int] = None,
            failure_reason: Optional[str] = None,
            compensation_step: Optional[int] = None,
        ) -> bool:
            """
            Returns True if the update succeeded. Using optimistic locking
            via a WHERE clause prevents concurrent workers from double-processing.
            """
            async with self.pool.acquire() as conn:
                result = await conn.execute("""
                    UPDATE saga_log
                    SET 
                        status = $2,
                        failed_at_step = COALESCE($3, failed_at_step),
                        failure_reason = COALESCE($4, failure_reason),
                        compensation_step = $5,
                        updated_at = NOW(),
                        retry_count = retry_count + 1
                    WHERE saga_id = $1
                """,
                    saga_id,
                    new_status.value,
                    failed_at_step,
                    failure_reason,
                    compensation_step,
                )
                return result == "UPDATE 1"
        
        async def find_stuck_sagas(self, older_than_minutes: int = 30) -> list[dict]:
            """Find sagas that started compensating but haven't finished."""
            async with self.pool.acquire() as conn:
                rows = await conn.fetch("""
                    SELECT * FROM saga_log
                    WHERE status IN ('FAILED', 'COMPENSATING', 'COMPENSATION_FAILED')
                      AND updated_at < NOW() - INTERVAL '1 minute' * $1
                    ORDER BY updated_at ASC
                """, older_than_minutes)
                return [dict(row) for row in rows]
    

    The recovery worker pattern: In production, run a separate lightweight process that queries find_stuck_sagas() every few minutes and re-enqueues them for compensation. This is your "dead letter queue" equivalent for sagas. Without this, a saga that fails during compensation will sit in COMPENSATING state forever until someone notices.


    Implementing the Saga Orchestrator

    Now we build the orchestrator — the central class that drives each step, handles failures, and coordinates compensation. The key design principle here is that every step and every compensating transaction is a separate async method, and the orchestrator wraps each one in consistent error handling and state persistence.

    import asyncio
    import asyncpg
    import redis.asyncio as aioredis
    from aiokafka import AIOKafkaProducer
    import json
    import logging
    from datetime import datetime
    
    logger = logging.getLogger(__name__)
    
    class OrderSagaOrchestrator:
        def __init__(
            self,
            pg_pool: asyncpg.Pool,          # orders database
            analytics_pool: asyncpg.Pool,    # analytics database  
            kafka_producer: AIOKafkaProducer,
            redis_client: aioredis.Redis,
            saga_repo: SagaRepository,
        ):
            self.pg = pg_pool
            self.analytics = analytics_pool
            self.kafka = kafka_producer
            self.redis = redis_client
            self.repo = saga_repo
        
        async def execute(self, order_data: dict) -> SagaInstance:
            """Entry point: create a new saga and execute all steps."""
            saga = SagaInstance(
                saga_id=str(uuid.uuid4()),
                order_data=order_data,
                status=SagaStatus.PENDING,
            )
            await self.repo.create(saga)
            
            logger.info(f"Starting saga {saga.saga_id} for order {order_data.get('order_id')}")
            
            try:
                await self._step_1_write_order(saga)
                await self._step_2_publish_kafka_event(saga)
                await self._step_3_update_inventory(saga)
                await self._step_4_write_analytics(saga)
                
                await self.repo.update_status(saga.saga_id, SagaStatus.COMPLETED)
                saga.status = SagaStatus.COMPLETED
                logger.info(f"Saga {saga.saga_id} completed successfully")
                return saga
                
            except SagaStepException as e:
                logger.error(f"Saga {saga.saga_id} failed at step {e.step}: {e}")
                saga.failed_at_step = e.step
                saga.failure_reason = str(e)
                saga.status = SagaStatus.FAILED
                await self.repo.update_status(
                    saga.saga_id, SagaStatus.FAILED,
                    failed_at_step=e.step,
                    failure_reason=str(e),
                )
                await self._compensate(saga)
                return saga
        
        # -------------------------------------------------------------------------
        # Forward Steps
        # -------------------------------------------------------------------------
        
        async def _step_1_write_order(self, saga: SagaInstance) -> None:
            try:
                async with self.pg.acquire() as conn:
                    await conn.execute("""
                        INSERT INTO orders 
                            (order_id, customer_id, product_id, quantity, 
                             total_amount, status, saga_id, created_at)
                        VALUES ($1, $2, $3, $4, $5, 'PENDING', $6, NOW())
                        ON CONFLICT (order_id) DO NOTHING
                    """,
                        saga.order_data["order_id"],
                        saga.order_data["customer_id"],
                        saga.order_data["product_id"],
                        saga.order_data["quantity"],
                        saga.order_data["total_amount"],
                        saga.saga_id,
                    )
                await self.repo.update_status(saga.saga_id, SagaStatus.STEP_1_COMPLETE)
                saga.status = SagaStatus.STEP_1_COMPLETE
            except Exception as e:
                raise SagaStepException(step=1, cause=e)
        
        async def _step_2_publish_kafka_event(self, saga: SagaInstance) -> None:
            try:
                event = {
                    "event_type": "order.created",
                    "saga_id": saga.saga_id,
                    "order_id": saga.order_data["order_id"],
                    "timestamp": datetime.utcnow().isoformat(),
                    "payload": saga.order_data,
                }
                await self.kafka.send_and_wait(
                    topic="order-events",
                    key=saga.order_data["order_id"].encode(),
                    value=json.dumps(event).encode(),
                    headers=[("saga_id", saga.saga_id.encode())],
                )
                await self.repo.update_status(saga.saga_id, SagaStatus.STEP_2_COMPLETE)
                saga.status = SagaStatus.STEP_2_COMPLETE
            except Exception as e:
                raise SagaStepException(step=2, cause=e)
        
        async def _step_3_update_inventory(self, saga: SagaInstance) -> None:
            try:
                product_id = saga.order_data["product_id"]
                quantity = saga.order_data["quantity"]
                inventory_key = f"inventory:{product_id}"
                
                # Lua script ensures atomic check-and-decrement
                lua_script = """
                    local current = tonumber(redis.call('GET', KEYS[1]))
                    if current == nil then
                        return redis.error_reply("INVENTORY_NOT_FOUND")
                    end
                    if current < tonumber(ARGV[1]) then
                        return redis.error_reply("INSUFFICIENT_INVENTORY")
                    end
                    return redis.call('DECRBY', KEYS[1], ARGV[1])
                """
                result = await self.redis.eval(lua_script, 1, inventory_key, quantity)
                
                if result < 0:
                    raise ValueError(f"Inventory went negative for product {product_id}")
                
                await self.repo.update_status(saga.saga_id, SagaStatus.STEP_3_COMPLETE)
                saga.status = SagaStatus.STEP_3_COMPLETE
            except Exception as e:
                raise SagaStepException(step=3, cause=e)
        
        async def _step_4_write_analytics(self, saga: SagaInstance) -> None:
            try:
                async with self.analytics.acquire() as conn:
                    await conn.execute("""
                        INSERT INTO analytics.order_facts
                            (order_id, customer_id, product_id, quantity,
                             total_amount, saga_id, event_time)
                        VALUES ($1, $2, $3, $4, $5, $6, NOW())
                        ON CONFLICT (order_id) DO UPDATE
                            SET saga_id = EXCLUDED.saga_id,
                                event_time = EXCLUDED.event_time
                    """,
                        saga.order_data["order_id"],
                        saga.order_data["customer_id"],
                        saga.order_data["product_id"],
                        saga.order_data["quantity"],
                        saga.order_data["total_amount"],
                        saga.saga_id,
                    )
            except Exception as e:
                raise SagaStepException(step=4, cause=e)
    

    Notice the Lua script in step 3. This is critical: Redis doesn't have native transactions with rollback, so the only way to do an atomic check-and-decrement ("only decrement if we have enough inventory") is with a Lua script that executes atomically on the Redis server. Without it, you'd have a race condition where two concurrent sagas both read the inventory, both see 1 unit available, and both decrement — resulting in -1 inventory.


    Implementing Compensating Transactions

    Here's where most tutorials fall short. They describe compensation conceptually but skip the hard parts: what happens when compensation itself fails? How do you ensure compensations are idempotent? How do you handle partial compensation failures?

        async def _compensate(self, saga: SagaInstance) -> None:
            """
            Run compensating transactions in reverse order from the last
            successful step. Each compensation is attempted with retries.
            """
            await self.repo.update_status(
                saga.saga_id, SagaStatus.COMPENSATING,
                compensation_step=saga.failed_at_step - 1,
            )
            
            # Determine which compensations to run based on how far we got
            steps_to_compensate = range(saga.failed_at_step - 1, 0, -1)
            
            for step in steps_to_compensate:
                success = await self._run_compensation_with_retry(saga, step)
                if not success:
                    logger.critical(
                        f"Compensation failed for saga {saga.saga_id} at step {step}. "
                        f"Manual intervention required."
                    )
                    await self.repo.update_status(
                        saga.saga_id, SagaStatus.COMPENSATION_FAILED,
                        compensation_step=step,
                    )
                    # Alert operations team here
                    await self._alert_operations(saga, step)
                    return
            
            await self.repo.update_status(
                saga.saga_id, SagaStatus.COMPENSATED,
                compensation_step=None,
            )
            saga.status = SagaStatus.COMPENSATED
            logger.info(f"Saga {saga.saga_id} fully compensated")
        
        async def _run_compensation_with_retry(
            self, saga: SagaInstance, step: int, max_retries: int = 5
        ) -> bool:
            """Retry a compensation step with exponential backoff."""
            compensation_map = {
                1: self._compensate_step_1,
                2: self._compensate_step_2,
                3: self._compensate_step_3,
            }
            compensate_fn = compensation_map[step]
            
            for attempt in range(max_retries):
                try:
                    await compensate_fn(saga)
                    return True
                except Exception as e:
                    wait = min(2 ** attempt, 30)  # cap at 30 seconds
                    logger.warning(
                        f"Compensation step {step} attempt {attempt + 1} failed "
                        f"for saga {saga.saga_id}: {e}. Retrying in {wait}s."
                    )
                    await asyncio.sleep(wait)
            
            return False
        
        async def _compensate_step_1(self, saga: SagaInstance) -> None:
            """
            Compensate the Postgres order write by marking it CANCELLED.
            This is idempotent: setting CANCELLED on an already-CANCELLED row is safe.
            """
            async with self.pg.acquire() as conn:
                await conn.execute("""
                    UPDATE orders
                    SET 
                        status = 'CANCELLED',
                        cancelled_at = NOW(),
                        cancellation_reason = 'saga_compensation',
                        saga_id = $2
                    WHERE order_id = $1
                      AND status != 'SHIPPED'  -- never cancel an already-shipped order
                """,
                    saga.order_data["order_id"],
                    saga.saga_id,
                )
        
        async def _compensate_step_2(self, saga: SagaInstance) -> None:
            """
            Compensate the Kafka publish by publishing an order.cancelled event.
            Kafka messages are immutable — we can't retract the order.created event.
            Consumers must handle both events and let cancelled win.
            """
            event = {
                "event_type": "order.cancelled",
                "saga_id": saga.saga_id,
                "order_id": saga.order_data["order_id"],
                "timestamp": datetime.utcnow().isoformat(),
                "reason": "saga_compensation",
                "compensating_for": "order.created",
            }
            await self.kafka.send_and_wait(
                topic="order-events",
                key=saga.order_data["order_id"].encode(),
                value=json.dumps(event).encode(),
                headers=[
                    ("saga_id", saga.saga_id.encode()),
                    ("event_type", b"compensating"),
                ],
            )
        
        async def _compensate_step_3(self, saga: SagaInstance) -> None:
            """
            Compensate the inventory decrement by incrementing back.
            We use a Lua script with idempotency tracking to prevent double-increments.
            """
            product_id = saga.order_data["product_id"]
            quantity = saga.order_data["quantity"]
            inventory_key = f"inventory:{product_id}"
            compensation_key = f"compensation:{saga.saga_id}:step3"
            
            lua_script = """
                -- Check if we've already compensated this saga step
                if redis.call('EXISTS', KEYS[2]) == 1 then
                    return redis.call('GET', KEYS[1])
                end
                
                -- Perform the increment and mark as compensated
                local new_value = redis.call('INCRBY', KEYS[1], ARGV[1])
                redis.call('SET', KEYS[2], '1', 'EX', 86400)  -- TTL 24h
                return new_value
            """
            await self.redis.eval(
                lua_script, 2, inventory_key, compensation_key, quantity
            )
    
    class SagaStepException(Exception):
        def __init__(self, step: int, cause: Exception):
            self.step = step
            self.cause = cause
            super().__init__(f"Step {step} failed: {cause}")
    

    The idempotency key in _compensate_step_3 deserves explanation. Because compensations are retried on failure, there's a real risk that a compensation runs, partially succeeds (the Redis INCRBY completes), and then crashes before acknowledging success to the saga orchestrator. On the next retry, the compensation would run again and increment the inventory twice. The Lua script checks for a compensation marker key first and returns early if it exists — making the entire operation idempotent regardless of how many times you call it.


    Handling the Hardest Failure Mode: Non-Idempotent External Systems

    Some of your sinks won't be under your control. You might be writing to a payment processor API, a CRM system, or a third-party fulfillment service. These systems often:

    • Have no idempotency keys
    • Don't support compensating operations (you can charge but not uncharge via API)
    • Have rate limits that make retries expensive

    The pattern here is the outbox table. Instead of calling the external system directly from your saga step, you write a record to an outbox table within the same local Postgres transaction as your business data write. A separate worker reads the outbox and makes the external call. This decouples the saga's atomicity from external system availability.

    CREATE TABLE outbox (
        id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        saga_id         UUID NOT NULL,
        aggregate_type  TEXT NOT NULL,      -- e.g., 'order'
        aggregate_id    TEXT NOT NULL,      -- e.g., order_id
        event_type      TEXT NOT NULL,      -- e.g., 'charge_customer'
        payload         JSONB NOT NULL,
        status          TEXT NOT NULL DEFAULT 'PENDING',
        attempts        INTEGER NOT NULL DEFAULT 0,
        last_attempted  TIMESTAMPTZ,
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    
    async def _step_2_write_to_outbox(self, saga: SagaInstance) -> None:
        """
        Write the external call intent to the outbox within the same
        Postgres transaction as the order write. Either both succeed or neither.
        """
        async with self.pg.acquire() as conn:
            async with conn.transaction():
                # The order write and outbox write are in the same transaction
                await conn.execute("""
                    UPDATE orders SET status = 'PROCESSING'
                    WHERE order_id = $1
                """, saga.order_data["order_id"])
                
                await conn.execute("""
                    INSERT INTO outbox 
                        (saga_id, aggregate_type, aggregate_id, event_type, payload)
                    VALUES ($1, 'order', $2, 'publish_order_event', $3)
                """,
                    saga.saga_id,
                    saga.order_data["order_id"],
                    json.dumps(saga.order_data),
                )
    

    This is called the Transactional Outbox Pattern, and it composes beautifully with Sagas. The saga step succeeds as soon as the outbox record is written. The compensation for this step deletes or voids the outbox record if the external worker hasn't processed it yet, or publishes a compensating call to the external system if it has.

    Warning: Don't use the outbox pattern as an excuse to skip thinking about compensation. If the outbox worker processes the external call successfully but then your saga fails at a later step, you still need to call the external API to reverse the operation. The outbox just decouples when you call it, not whether you need a compensating action.


    Designing for Observability

    A saga that fails silently is worse than one that fails loudly. You need three things: structured logging on every state transition, metrics on saga duration and failure rates, and a way to query saga state from an operations dashboard.

    import structlog
    from prometheus_client import Counter, Histogram, Gauge
    import time
    
    saga_started = Counter("saga_started_total", "Total sagas started", ["pipeline"])
    saga_completed = Counter("saga_completed_total", "Total sagas completed", ["pipeline"])
    saga_failed = Counter("saga_failed_total", "Total sagas that failed", ["pipeline", "step"])
    saga_compensated = Counter("saga_compensated_total", "Total sagas fully compensated", ["pipeline"])
    saga_compensation_failed = Counter(
        "saga_compensation_failed_total",
        "Sagas where compensation itself failed — needs human",
        ["pipeline", "step"]
    )
    saga_duration = Histogram(
        "saga_duration_seconds",
        "Time from saga start to completion or compensation",
        ["pipeline", "outcome"],
        buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60, 120],
    )
    
    class InstrumentedSagaOrchestrator(OrderSagaOrchestrator):
        async def execute(self, order_data: dict) -> SagaInstance:
            log = structlog.get_logger().bind(
                pipeline="order_processing",
                order_id=order_data.get("order_id"),
            )
            start_time = time.monotonic()
            saga_started.labels(pipeline="order_processing").inc()
            
            saga = await super().execute(order_data)
            
            duration = time.monotonic() - start_time
            
            if saga.status == SagaStatus.COMPLETED:
                saga_completed.labels(pipeline="order_processing").inc()
                saga_duration.labels(
                    pipeline="order_processing", outcome="completed"
                ).observe(duration)
                log.info("saga_completed", saga_id=saga.saga_id, duration_s=round(duration, 3))
            
            elif saga.status == SagaStatus.COMPENSATED:
                saga_failed.labels(
                    pipeline="order_processing", step=str(saga.failed_at_step)
                ).inc()
                saga_compensated.labels(pipeline="order_processing").inc()
                saga_duration.labels(
                    pipeline="order_processing", outcome="compensated"
                ).observe(duration)
                log.warning(
                    "saga_compensated",
                    saga_id=saga.saga_id,
                    failed_at_step=saga.failed_at_step,
                    reason=saga.failure_reason,
                    duration_s=round(duration, 3),
                )
            
            elif saga.status == SagaStatus.COMPENSATION_FAILED:
                saga_compensation_failed.labels(
                    pipeline="order_processing",
                    step=str(saga.compensation_step),
                ).inc()
                log.critical(
                    "saga_compensation_failed_needs_human",
                    saga_id=saga.saga_id,
                    failed_at_step=saga.failed_at_step,
                    compensation_step=saga.compensation_step,
                )
            
            return saga
    

    For the operations dashboard, the saga_log table is your source of truth. A simple query to understand your pipeline health:

    -- Saga health summary for the last 24 hours
    SELECT 
        status,
        COUNT(*) as count,
        AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_duration_seconds,
        MAX(retry_count) as max_retries
    FROM saga_log
    WHERE created_at > NOW() - INTERVAL '24 hours'
    GROUP BY status
    ORDER BY count DESC;
    
    -- Find all sagas stuck in compensation for more than an hour
    SELECT saga_id, failed_at_step, failure_reason, compensation_step, updated_at
    FROM saga_log
    WHERE status IN ('COMPENSATING', 'COMPENSATION_FAILED')
      AND updated_at < NOW() - INTERVAL '1 hour'
    ORDER BY updated_at ASC;
    

    Hands-On Exercise

    In this exercise, you'll extend the saga to handle a fifth step: calling a shipping provider's REST API to reserve a delivery slot. This step is non-idempotent (each call creates a new reservation) and has a compensating operation (cancel the reservation using the returned reservation ID).

    Setup: Use httpx for async HTTP calls and assume the shipping API:

    • POST /reservations → { "reservation_id": "...", "slot": "..." }
    • DELETE /reservations/{reservation_id} → 204 on success

    Your tasks:

    1. Add a STEP_5_COMPLETE state to SagaStatus and update the state machine diagram.

    2. Implement _step_5_reserve_shipping_slot(saga) that calls the API and stores the reservation_id in the order_data dict (so it's available to the compensating transaction).

      Hint: you'll need to persist the reservation_id back to saga_log.order_data after the API call succeeds. Use a UPDATE saga_log SET order_data = order_data || $1 with the new field.

    3. Implement _compensate_step_5(saga) that:

      • Reads the reservation_id from saga.order_data
      • Calls DELETE /reservations/{reservation_id}
      • Handles the case where the reservation doesn't exist (API returns 404) — this should be treated as a successful compensation, not an error, because it means the reservation was never created or was already cancelled.
    4. Modify _compensate to include step 5 in the compensation chain. Remember: if the saga fails at step 5, compensation runs steps 4 → 3 → 2 → 1. If it fails at step 4, step 5's compensation shouldn't run (it never succeeded).

    5. Write a test that simulates the shipping API returning 503 on the first two attempts and 200 on the third. Verify the saga completes successfully and the retry_count in the saga log reflects the retries.


    Common Mistakes & Troubleshooting

    Mistake 1: Using the saga orchestrator as a state machine without persisting state between steps.

    If you only keep saga state in memory, a process crash between step 2 and step 3 leaves you with no record of what happened. You can't run compensation because you don't know how far the saga progressed. Always persist state to durable storage before marking a step complete, not after.

    Mistake 2: Compensating transactions that aren't idempotent.

    If your step 3 compensation is DELETE FROM reservations WHERE saga_id = $1 and it fails halfway through, the second attempt will silently succeed but do nothing — which sounds fine until you realize the deletion might have only partially run on a partitioned table and the second run leaves a dangling row. Use UPDATE ... SET status = 'CANCELLED' patterns instead, and add idempotency keys to Redis operations.

    Mistake 3: Not ordering compensation correctly.

    Compensation must run in strict reverse order because later steps may depend on state set by earlier steps. If step 3 reads data written by step 1, compensating step 1 before step 3 can leave step 3 in an inconsistent state that its compensation can't handle.

    Mistake 4: Treating COMPENSATION_FAILED as a recoverable state.

    When compensation fails after retries, you have a genuinely inconsistent state. Don't keep retrying automatically — escalate to human intervention immediately. Your compensation may be making incorrect assumptions about the current state (maybe the downstream system changed schema, or an external API changed its behavior). Infinite automatic retries can make the inconsistency worse.

    Mistake 5: Publishing Kafka compensation events to a different topic than the forward events.

    Consumers that process order.created events need to be able to see order.cancelled events to know to stop processing. If you publish compensating events to a different topic, consumers of the original topic will never know the saga was rolled back. Use the same topic with the same partition key so that ordering guarantees apply.

    Troubleshooting: Saga stuck in COMPENSATING for hours.

    Check the compensation_step column — it tells you exactly which step's compensation is failing. Then look at the logs for that specific step. The most common causes are: the downstream system is rate-limiting your retries (add jitter to backoff), the idempotency key expired in Redis (extend TTL), or the downstream system's API changed and your compensation call is malformed (check the API response body in your logs, not just the status code).

    Troubleshooting: Duplicate saga executions creating duplicate rows.

    This happens when your pipeline consumer framework retries message delivery and your saga's step 1 doesn't use ON CONFLICT DO NOTHING or similar upsert semantics. Ensure every saga step is fully idempotent by using the order_id as a natural key with conflict handling. The saga_id will differ between attempts but the order_id should be stable.


    Summary & Next Steps

    You've now built a production-grade distributed saga implementation for multi-sink data pipelines. The core ideas to carry with you:

    • Distributed atomicity is impossible across heterogeneous systems. The Saga pattern accepts this and provides eventual consistency with explicit, reversible compensation steps.
    • Compensation is not rollback. Compensating transactions are new, forward-moving writes that logically negate earlier writes. They must be idempotent, retried with backoff, and tracked in persistent state.
    • Saga state persistence is non-negotiable. A saga that only exists in memory cannot survive process failures. Persist state transitions to a durable store before considering a step complete.
    • Compensation failure is a different beast from step failure. Step failures are handled by running compensation. Compensation failures require human intervention. Build your alerting to treat COMPENSATION_FAILED as a P1 incident.
    • The Transactional Outbox pattern composes with Sagas to handle non-idempotent external systems by deferring the call to a separate worker while maintaining atomicity with the local database write.

    Where to go from here:

    If you're running this in a distributed environment with multiple pipeline workers, you'll need to add distributed locking to prevent two workers from picking up the same stuck saga. Investigate Redis SETNX-based locks or PostgreSQL advisory locks for this.

    For higher throughput scenarios, look into event-sourced saga state: instead of a single saga_log row that gets updated, append a row for every state transition. This gives you a complete audit trail and avoids update contention, at the cost of more complex state reconstruction.

    Finally, if you find yourself writing sagas for many different pipelines, consider adopting a workflow orchestration tool like Temporal or Conductor that provides the state machine infrastructure out of the box and lets you focus on writing your step and compensation logic. The patterns you've learned here are exactly what those tools implement under the hood — understanding them from first principles makes you dramatically more effective when using higher-level abstractions.

    Learning Path: Data Pipeline Fundamentals

    Previous

    Partitioning Strategies for Pipeline Output: Optimizing Downstream Query Performance with Date, Hash, and Range Partitioning

    Related Articles

    Data Engineering⚡ Practitioner

    Building and Managing dbt Packages: Reusable Macros, Models, and Tests Across Projects

    20 min
    Data Engineering⚡ Practitioner

    Partitioning Strategies for Pipeline Output: Optimizing Downstream Query Performance with Date, Hash, and Range Partitioning

    21 min
    Data Engineering🌱 Foundation

    Scheduling and Backfilling Historical Data Loads in Airflow: Catchup, DAG Runs, and Idempotent Pipeline Design

    16 min

    On this page

    • Introduction
    • Prerequisites
    • Why Distributed Transactions Don't Actually Solve This
    • Understanding the Saga Pattern
    • Modeling the Saga State Machine
    • Persisting Saga State
    • Implementing the Saga Orchestrator
    • Implementing Compensating Transactions
    • Handling the Hardest Failure Mode: Non-Idempotent External Systems
    • Designing for Observability
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps