Skip to main content
data systems mechanics invariants in distributed architectures

Distributed Transactions

6 min read Chapter 16 of 28
Summary

This section provides a comprehensive analysis of distributed...

This section provides a comprehensive analysis of distributed transaction protocols, focusing on the Two-Phase Commit (2PC) mechanism and its fundamental limitations. The core guarantee of 2PC is atomicity across distributed nodes, achieved through a prepare phase (participant voting) and commit phase (coordinator decision). The protocol's critical weakness is its blocking nature: if the coordinator fails after participants vote YES but before sending the final decision, participants remain in an 'in-doubt' state with locked resources, causing indefinite delays. Recovery requires the complex xa_recover() process. As an alternative, the Saga pattern decomposes global transactions into local transactions with compensating operations, trading immediate atomicity for availability and non-blocking behavior via eventual consistency. The comparison table highlights the immutable trade-off: 2PC offers strong consistency but blocks; Saga offers availability but requires compensation logic. Code examples illustrate the coordinator crash point in 2PC and the application-specific nature of Saga compensation. Key entities introduced include the Coordinator, Participants (Resource Managers), the XA Standard interfaces, and Compensating Transactions as business-level inverses.

Distributed Transactions: Two-Phase Commit and Beyond

Distributed transactions enforce atomicity across multiple nodes, ensuring data consistency in distributed systems. The Two-Phase Commit (2PC) protocol provides strong all-or-nothing guarantees through a centralized coordination mechanism. The Saga pattern abandons atomicity for availability, replacing rollback with compensating actions. These are not interchangeable techniques—they represent divergent architectural commitments with irreversible consequences.

Two-Phase Commit (2PC) Protocol

2PC guarantees atomic commitment across distributed participants. This invariant requires a two-phase handshake: Prepare, where participants promise to commit; and Commit, where the coordinator broadcasts the final decision. Failure at any stage forces recovery protocols that expose the cost of consistency.

Prepare Phase

The coordinator initiates the Prepare phase by sending a prepare request to all participants. Each participant must durably record its local state and vote yes only if it can guarantee future commit capability. A no vote immediately invalidates the transaction. All participants that vote yes enter a committed-prepared state: they cannot abort unilaterally, even on failure.

This phase enforces the atomicity invariant at the cost of liveness. Once a participant votes yes, it surrenders autonomy to the coordinator. The system trades availability for consistency, a direct manifestation of the CAP theorem: during network partitions or coordinator outages, prepared participants stall indefinitely.

Commit Phase

If all participants vote yes, the coordinator proceeds to the Commit phase. It durably logs the decision and broadcasts a commit message. Each participant applies changes and acknowledges. If any participant voted no or failed during Prepare, the coordinator broadcasts an abort, triggering local rollbacks.

The commit decision is irrevocable. Participants must eventually apply it, regardless of transient failures. In practice, this forces infinite retry loops in the coordinator or participant recovery logic—availability collapses under coordination loss.

Blocking Nature of 2PC

2PC is inherently blocking. If the coordinator fails after the Prepare phase but before broadcasting the decision, all prepared participants remain in-doubt. They cannot independently resolve their state without risking atomicity violations. This is not a flaw—it is the necessary price of strict consistency.

Under the CAP theorem, 2PC chooses consistency over availability. During coordinator failure, the system halts progress until recovery. There is no workaround: any unilateral decision by a participant risks divergence, breaking the all-or-nothing guarantee. This makes 2PC unsuitable for systems requiring high availability or spanning unreliable networks.

Recovery via xa_recover

Recovery from coordinator failure demands a new coordinator invoke xa_recover() to poll participants for in-doubt transactions. The outcome depends on quorum visibility: if all responding participants are prepared, the transaction must commit; if any rejected, it must abort.

This process introduces operational complexity. Participants may be unreachable, requiring manual intervention. Logs must persist across crashes, increasing storage overhead. Network delays compound recovery time. In large-scale deployments, this leads to extended unavailability—proof that 2PC’s consistency guarantee comes with unbounded latency risk.

Saga Pattern: An Alternative

The Saga pattern abandons atomicity to preserve availability. It decomposes a global transaction into a sequence of local, immediately committed operations. Each step has a compensating action that reverses its effect if a subsequent step fails.

Sagas accept eventual consistency. They do not block on failure. Instead, they progress forward, using compensation to converge toward a valid business state. This shift—from rollback to undo—redefines fault tolerance.

Compensating Transactions

Compensating transactions are application-specific inverses, not generic rollbacks. They are not guaranteed to be perfect: inventory restocking may not reclaim the exact reserved units; financial reversals may incur fees. Yet they maintain business invariants under failure.

Compensation is irreversible and must be idempotent. Unlike 2PC’s abort—which reverts uncommitted state—compensation acts on already-committed data. Retrying a compensation must not double-apply effects. This requires careful design: versioning, idempotency keys, and state checks are mandatory.

Comparison of 2PC and Saga

ProtocolAtomicity GuaranteeBlocking ProblemConsistency ModelPrimary Use Case
Two-Phase Commit (2PC)Strong (All-or-nothing)Yes (Coordinator failure after Prepare)Immediate Consistency (ACID)Short, distributed database transactions (e.g., XA)
Saga PatternEventual (via compensation)NoEventual ConsistencyLong-running business processes (e.g., order fulfillment)

The choice between 2PC and Saga is binary. Use 2PC only when atomicity is non-negotiable and availability can be sacrificed. Use Saga when business continuity under failure is paramount and eventual consistency is acceptable. There is no middle ground.

Example Code: Two-Phase Commit Coordinator

from dataclasses import dataclass
from typing import List
import asyncio

@dataclass
class Participant:
    name: str

    async def prepare(self, tx_id: str) -> bool:
        await asyncio.sleep(0.1)
        if self.name == "ParticipantB":
            print(f"{self.name}: Vote NO (simulated constraint violation)")
            return False
        print(f"{self.name}: Vote YES")
        return True

    async def commit(self, tx_id: str):
        print(f"{self.name}: Committing {tx_id}")
        await asyncio.sleep(0.05)

    async def abort(self, tx_id: str):
        print(f"{self.name}: Aborting {tx_id}")
        await asyncio.sleep(0.05)

async def two_phase_commit_coordinator(participants: List[Participant], tx_id: str) -> bool:
    prepared_participants = []

    print(f"[Coordinator] Starting 2PC for transaction {tx_id}")
    print("[Coordinator] Phase 1: Sending PREPARE to all participants")
    for p in participants:
        try:
            vote = await p.prepare(tx_id)
            if vote:
                prepared_participants.append(p)
            else:
                print(f"[Coordinator] Abort: {p.name} voted NO")
                for prep in prepared_participants:
                    await prep.abort(tx_id)
                return False
        except Exception as e:
            print(f"[Coordinator] Abort due to error from {p.name}: {e}")
            for prep in prepared_participants:
                await prep.abort(tx_id)
            return False

    # --- COORDINATOR CRASH POINT ---
    # If the coordinator fails here, all prepared participants are BLOCKED.
    # Recovery requires external intervention via xa_recover().

    print(f"[Coordinator] Phase 2: All votes YES. Sending COMMIT")
    for p in prepared_participants:
        try:
            await p.commit(tx_id)
        except Exception as e:
            print(f"[Coordinator] Failed to commit {p.name}: {e}. Requires recovery.")
    print(f"[Coordinator] Transaction {tx_id} committed successfully.")
    return True

async def main():
    participants = [Participant("ParticipantA"), Participant("ParticipantB"), Participant("ParticipantC")]
    success = await two_phase_commit_coordinator(participants, "TX123")
    print(f"Transaction succeeded? {success}")

asyncio.run(main())

Example Code: Saga Pattern

def reserve_inventory(order_id: str, item_sku: str, quantity: int) -> bool:
    print(f"Reserving {quantity} of {item_sku} for order {order_id}")
    return True


def compensate_reserve_inventory(order_id: str, item_sku: str, quantity: int) -> bool:
    print(f"Cancelling reservation for {quantity} of {item_sku} for order {order_id}")
    return True


def charge_credit_card(order_id: str, amount: float) -> bool:
    raise Exception("Payment gateway timeout")

# --- Saga Orchestrator Logic ---
order_details = {"order_id": "O789", "item_sku": "BOOK123", "quantity": 2, "amount": 29.99}

completed_steps = []

try:
    print("Step 1: Create Order - COMMITTED")
    completed_steps.append("create_order")

    if reserve_inventory(order_details["order_id"], order_details["item_sku"], order_details["quantity"]):
        completed_steps.append("reserve_inventory")

    charge_credit_card(order_details["order_id"], order_details["amount"])
    completed_steps.append("charge_card")

    print("Saga completed successfully.")

except Exception as e:
    print(f"\nSaga failed at step 3: {e}")
    print("Initiating compensation in reverse order...")

    if "reserve_inventory" in completed_steps:
        compensate_reserve_inventory(order_details["order_id"], order_details["item_sku"], order_details["quantity"])
    if "create_order" in completed_steps:
        print("Compensating: Cancel Order (e.g., update status to 'cancelled')")

print("\nContrast with 2PC: In 2PC, none of these DB commits would have happened until Phase 2. A failure would trigger a generic abort/rollback on all participants.")

Conclusion

Choose 2PC only when atomicity is mandatory and system-wide unavailability during failures is acceptable. Its blocking nature is not a defect—it is the enforcement mechanism for consistency. Choose Saga when availability and progress are non-negotiable and business logic can define compensating actions. No architecture achieves both strong consistency and high availability under partition; the protocol choice determines which guarantee is sacrificed.