Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 58

Saga Pattern for Distributed Transactions

Recommended resources

Hello! Welcome to your daily lesson on distributed systems.

In our last session, we delved into the Two-Phase Commit (2PC) protocol. We saw how it provides strong atomicity but at the high cost of availability, primarily due to its blocking nature. You correctly identified that in high-availability scenarios, a different approach is needed. Today, we explore that alternative.

Your goal for this lesson is to apply the Saga pattern for managing long-running, distributed transactions across microservices. The Saga pattern is one of the most important patterns in modern microservice architectures, championed by companies like Netflix and Amazon. Mastering it means understanding how to build resilient systems that favor availability and are designed to handle partial failures gracefully.

We'll structure today's lesson into five interconnected concepts, moving from the core philosophy to advanced, interview-ready trade-offs:

  1. The Saga Philosophy: Embracing Failure with Compensation
  2. Choreography vs. Orchestration: Who's the Conductor?
  3. The Price of Availability: Lack of Isolation & Data Anomalies
  4. Countermeasures: Building Application-Level Isolation
  5. Elite Interview Deep Dive: Idempotency, Recovery, and Real-World Sagas

1. The Saga Philosophy: Embracing Failure with Compensation (Easy)

In our last lesson, we saw that 2PC is like a strict contract: everyone commits or no one does, and everyone waits for the final decision. The Saga pattern takes a more optimistic and flexible approach. It breaks a global transaction into a sequence of smaller, independent local transactions.

  • Technical Explanation:
    A Saga is a sequence of local transactions where each transaction updates data within a single service and publishes an event or message that triggers the next transaction in the sequence. If a local transaction fails, the Saga executes a series of compensating transactions to reverse the preceding transactions' effects.

    A Saga can be formally described as a sequence of forward transactions and their corresponding compensating transactions:

    • Forward transactions:
    • Compensating transactions:

    If fails, the Saga executes the compensating transactions in reverse order: . This ensures that the system returns to a semantically consistent state. Note that this is not a true database rollback; it's an application-level reversal of actions.

Applying the Saga Pattern • Caitie McCaffrey • GOTO 2015

To understand the origin and core idea of the Saga pattern, let's watch this segment from a classic talk by Caitie McCaffrey, who implemented Sagas for the Halo game series.

Watch from 08:32 to 11:54. Focus on how the original 1987 paper defines a Saga: a sequence of transactions with corresponding compensating transactions. Pay attention to the guarantee: either all transactions complete, or a subset completes and their compensations run.

  • Metaphor for a 5-Year-Old:
    You are planning your friend's birthday party.

    1. Step 1 (): You order and pay for a cake.
    2. Step 2 (): You book a bouncy castle.
    3. Step 3 (): You send out invitations.

    What if the bouncy castle company calls back and says they are fully booked (Step fails)? You don't just give up. You execute compensating actions:

    1. Compensation for Step 1 (): You call the bakery and cancel the cake order.

    The end result is consistent: no party, no cake. You never blocked waiting for the bouncy castle; you simply reacted to the failure.



2. Choreography vs. Orchestration: Who's the Conductor? (Medium)

A key design decision when implementing a Saga is how to coordinate the steps. There are two primary approaches: Choreography and Orchestration.

  • Technical Explanation:
    1. Choreography: In this decentralized approach, services communicate by publishing and subscribing to events. There is no central coordinator. When a service completes its local transaction, it publishes an event. Other services listen for these events and trigger their own local transactions accordingly.

      • Pros: Simple, loosely coupled services.
      • Cons: The overall workflow is not explicitly defined in one place, making it hard to track and debug. Can lead to cyclic dependencies between services.
    2. Orchestration: This centralized approach uses an "orchestrator" object or service to manage the Saga. The orchestrator is responsible for telling each participant what to do and in what order. It sends command messages to services and waits for reply messages, then calls the next step based on the outcome.

      • Pros: Centralized logic, easier to understand and manage the workflow. Clear separation of concerns.
      • Cons: Risk of centralizing too much business logic in the orchestrator. The orchestrator becomes another component to manage.
Understanding the Saga Pattern: Orchestration vs Choreography
This diagram visually contrasts the two Saga patterns. Orchestration uses a central coordinator sending commands, while Choreography relies on services reacting to events from each other.

Saga Pattern | Distributed Transactions | Microservices

The following video does an excellent job of explaining and contrasting these two approaches with clear diagrams and trade-offs.

Watch from 07:45 to 12:59. Focus on understanding the difference between the central Saga orchestrator sending commands (Orchestration) and services communicating directly through events (Choreography). Note the pros and cons listed for each.

  • Metaphor for a 5-Year-Old:

    • Choreography (Flash Mob): A group of dancers decide to perform a flash mob. They don't have a director shouting instructions. Instead, they all know to listen for a specific song to start. The first dancer starts, and the dancer next to them sees that and starts, and so on. They react to each other's actions (events).
    • Orchestration (Symphony Orchestra): A symphony orchestra has a conductor (the orchestrator). The conductor explicitly points to the violin section and tells them to play (sends a command). When they finish, the conductor points to the trumpet section to play. The conductor manages the entire flow.
  • Python Code Snippet (Conceptual Structure):

    # --- Orchestration ---
    class OrderSagaOrchestrator:
        def place_order(self, order_details):
            # Step 1: Reserve items
            payment_service.reserve_credit(order_details)
            # Step 2: Process payment
            inventory_service.reserve_items(order_details)
            # Step 3: Ship
            shipping_service.schedule_shipment(order_details)
            # ... handles failures with compensating commands
    
    # --- Choreography ---
    class PaymentService:
        def on_order_created_event(self, event):
            # Reserve credit and publish CreditReservedEvent
            pass
    
    class InventoryService:
        def on_credit_reserved_event(self, event):
            # Reserve items and publish ItemsReservedEvent
            pass
        
        def on_payment_failed_event(self, event):
            # Execute compensating transaction: release reserved items
            pass
    


3. The Price of Availability: Lack of Isolation & Data Anomalies (Hard)

Sagas gain availability by giving up one of ACID's core properties: Isolation. Because each local transaction commits immediately, its changes are visible to other transactions before the entire Saga is complete. This can lead to data anomalies.

  • Technical Explanation:
    The lack of isolation can cause several classic database anomalies:
    • Lost Updates: One Saga overwrites changes made by another concurrent Saga without reading them first. Example: A CreateOrder Saga approves an order just after a CancelOrder Saga has canceled it, effectively "losing" the cancellation update.
    • Dirty Reads: A Saga reads data from another Saga that has not yet completed. If the other Saga later aborts and compensates, the first Saga has made a decision based on data that is now invalid. Example: A CreateOrder Saga reads an increased credit limit from a CancelOrder Saga, but the cancellation later fails and is rolled back. The new order has now violated the credit limit.
    • Fuzzy/Non-repeatable Reads: A Saga reads the same data at two different points in time and gets different values because another Saga has modified it in the meantime.

Microservices Pattern: Distributed Transactions (SAGA)

This blog post provides an excellent, in-depth explanation of the isolation problem and the anomalies it creates. Understanding this is crucial for advanced system design discussions.

Read the sections titled 'Handling the lack of isolation' and 'Overview of anomalies', down to just before 'Countermeasures'. Focus on the definitions of Lost Updates and Dirty Reads and the examples provided.

  • Metaphor for a 5-Year-Old (Dirty Read):
    You and your sister share a digital piggy bank.
    1. Your sister starts a "transaction" to deposit $10 she got from grandma. You see the balance go up by $10.
    2. Thinking you're rich, you immediately start your own transaction and spend that $10 on a new toy.
    3. But, your sister's deposit fails (maybe grandma's check bounced), and her transaction is compensated (the $10 deposit is removed).
      You have performed a dirty read: you acted on money that wasn't really there, leaving the piggy bank in a negative state.


4. Countermeasures: Building Application-Level Isolation (Harder)

Since Sagas don't have built-in isolation, you must implement countermeasures at the application level to prevent or mitigate these anomalies.

  • Technical Explanation:
    These strategies add a layer of logic to your Saga to handle concurrency.
    • Semantic Lock: An application-level lock. A Saga marks a record it's using with a flag (e.g., changing an order's state to PENDING_APPROVAL). Other Sagas see this flag and know not to modify the record, or to wait. This is one of the most common and effective countermeasures.
    • Commutative Updates: Design update operations so they can be executed in any order without changing the final result (e.g., credit() and debit() on an account balance). This avoids lost update issues.
    • Pessimistic View: Re-order the steps of a Saga to minimize business risk. For example, in a CancelOrder Saga, don't refund the customer's credit (IncreaseCredit) until after you've confirmed the order is irreversibly canceled (ChangeStateToCancelled). This prevents a dirty read of the available credit.
    • Reread Value: A form of optimistic locking. Before a Saga updates a record, it re-reads the record to verify no other transaction has changed it. If it has changed, the Saga aborts.

Microservices Pattern: Distributed Transactions (SAGA)

The same blog post provides a catalog of these countermeasures. This is exactly the kind of detailed knowledge that distinguishes a top-tier candidate.

Read the section 'Countermeasures for handling the lack of isolation'. Focus on understanding Semantic Lock, Commutative Updates, and Pessimistic View.

  • Python Code Snippet (Orchestrated Saga with Semantic Lock):
    Let's re-implement our food delivery example from the last lesson, but with an Orchestrated Saga and a semantic lock (state field).

    import uuid
    
    # Services are simplified for clarity. They would be separate microservices.
    class FoodService:
        def reserve_food(self, food_id): print(f"FoodService: Reserved {food_id}."); return True
        def release_food(self, food_id): print(f"FoodService: Released {food_id}.")
    
    class PaymentService:
        def process_payment(self, user_id): print(f"PaymentService: Processed payment for {user_id}."); return True
        def refund_payment(self, user_id): print(f"PaymentService: Refunded payment for {user_id}.")
    
    class Order:
        def __init__(self, user_id, food_id):
            self.order_id = str(uuid.uuid4())
            self.user_id = user_id
            self.food_id = food_id
            # Semantic Lock: The state field manages the Saga's progress
            self.state = "PENDING"
    
    # Orchestrator
    class OrderSagaOrchestrator:
        def __init__(self, food_service, payment_service):
            self.food_service = food_service
            self.payment_service = payment_service
    
        def execute_order(self, user_id, food_id):
            order = Order(user_id, food_id)
            print(f"\n--- Starting Saga for Order {order.order_id} ---")
            order.state = "RESERVING_FOOD"
            
            # Step 1: Reserve Food
            if self.food_service.reserve_food(order.food_id):
                order.state = "PROCESSING_PAYMENT"
                
                # Step 2: Process Payment
                if self.payment_service.process_payment(order.user_id):
                    order.state = "COMPLETED"
                    print(f"Saga for Order {order.order_id} successful. State: {order.state}")
                    return True
                else:
                    # Payment failed, start compensation
                    print("Payment failed. Compensating...")
                    order.state = "CANCELLING"
                    self.food_service.release_food(order.food_id) # Compensate Step 1
                    order.state = "CANCELLED"
                    print(f"Saga for Order {order.order_id} failed. State: {order.state}")
                    return False
            else:
                # Food reservation failed, no compensation needed yet
                order.state = "CANCELLED"
                print(f"Saga for Order {order.order_id} failed. State: {order.state}")
                return False
    
    # --- Run simulation ---
    food_svc = FoodService()
    payment_svc = PaymentService()
    orchestrator = OrderSagaOrchestrator(food_svc, payment_svc)
    
    # Success Case
    orchestrator.execute_order("user123", "burger")
    
    # Failure Case
    # Simulate payment failure
    class FailingPaymentService:
        def process_payment(self, user_id): print(f"PaymentService: Payment FAILED for {user_id}."); return False
        def refund_payment(self, user_id): print(f"PaymentService: Refunded payment for {user_id}.")
    
    failing_orchestrator = OrderSagaOrchestrator(food_svc, FailingPaymentService())
    failing_orchestrator.execute_order("user456", "pizza")
    


5. Elite Interview Deep Dive: Idempotency, Recovery, and Real-World Sagas (Elite)

To truly master Sagas, you must consider how the system recovers from failures within the Saga logic itself.

  • Technical Explanation:

    • Idempotency is Critical: What happens if you try to refund a customer, but your network times out? You don't know if the refund processed. You must retry. If you retry and it had processed, you might refund them twice! Therefore, compensating transactions must be idempotent. An idempotent operation can be performed multiple times with the same effect as performing it once (e.g., SET status='REFUNDED' is idempotent; credit(amount) is not).
    • Forward vs. Backward Recovery: The examples so far use backward recovery (rolling back via compensations). Some business processes can't be rolled back (e.g., sending an email). In these cases, you might use forward recovery, where the Saga retries the failed step until it succeeds. The Halo 4 stats system used this: if a player's stats failed to update, the system would keep retrying rather than rolling back the stats for the other 31 players.
    • Orchestrator Failure: If you use an orchestrator, it must be stateful and resilient. If the orchestrator crashes, it must be able to recover and resume the Saga from where it left off. This is often achieved by persisting the Saga's state to a database after each step.
  • Real-World Example (Netflix):
    When you sign up for Netflix, a Saga coordinates multiple services:

    1. T1: Create customer account.
    2. T2: Process billing information.
    3. T3: Set up content preferences/recommendations.
    4. T4: Send a welcome email.

    This process must be highly available. If the billing service is temporarily down (T2 fails), Netflix doesn't want to block all new signups (like 2PC would). Instead, the Saga compensates by deleting the customer account (C1). The user gets an error message and can try again shortly, but the system remains available for other users.

Test your understanding! (Interview-Style Question)

You are designing a 'Book Trip' feature for a travel website. The workflow involves booking a flight, a hotel, and a rental car, each handled by a separate microservice. The CEO has stated that "under no circumstances should a customer be charged for a flight if their hotel booking fails."

Which Saga style (Choreography or Orchestration) would you choose and why? Describe the high-level flow and name one key countermeasure you would implement to satisfy the CEO's requirement.

Show answer

I would choose the Orchestration style for this Saga.

Reasoning:

  • Clear Business Process: Booking a trip is a complex, multi-step process with a clear beginning and end. An orchestrator makes this explicit, which is easier to debug, monitor, and modify than a decentralized web of events in a choreography model.
  • Failure Management: Centralized control makes it easier to manage the complex failure logic, especially given the CEO's strict requirement.

High-Level Flow (Orchestrated):

  1. An TripSagaOrchestrator is created when the user clicks 'Book Trip'.
  2. Step 1: The orchestrator sends a ReserveFlight command to the FlightService.
  3. Step 2: Upon receiving FlightReserved, the orchestrator sends a ReserveHotel command to the HotelService.
  4. Step 3: If the HotelService replies with HotelReservationFailed, the orchestrator begins compensation. It sends a CancelFlightReservation command to the FlightService.
  5. Step 4: Only if all reservations succeed does the orchestrator proceed to send ConfirmPayment commands to each service.

Key Countermeasure:
The most critical countermeasure is the Semantic Lock. When the FlightService reserves a flight, it should mark that flight record with a state like PENDING_CONFIRMATION, not BOOKED. The flight is only moved to a final BOOKED state after the entire Saga (including successful hotel and car reservations) is complete. The compensating transaction (CancelFlightReservation) would simply delete the PENDING_CONFIRMATION record. This prevents any 'dirty reads' where another part of the system might see a flight as booked before the trip is fully confirmed.



Conclusion

Today, you've moved beyond the rigid world of 2PC into the flexible and highly-available domain of the Saga pattern. You've learned how it uses local transactions and compensations to maintain consistency in distributed systems.

Key Takeaways:

  • The Saga pattern ensures eventual consistency by executing a sequence of local transactions and running compensating transactions to undo steps upon failure.
  • Sagas can be implemented via Choreography (decentralized, event-driven) or Orchestration (centralized, command-driven), each with distinct trade-offs.
  • The primary drawback of Sagas is their lack of isolation, which can lead to data anomalies like dirty reads and lost updates.
  • Countermeasures like Semantic Locks are essential application-level techniques to mitigate these anomalies.
  • Production-ready Sagas require careful design of idempotent compensating transactions and robust recovery strategies.

Next Lesson Preview

We've now covered two heavyweight patterns for coordinating work across services. In our next lesson, we'll examine a pattern that focuses on optimizing performance by fundamentally rethinking how we handle reads and writes. We will explore how to apply the CQRS (Command Query Responsibility Segregation) pattern for scalable read/write separation, a powerful technique for building high-performance systems.