1. The Problem & Why It Matters
In a monolithic architecture backed by a single relational database, maintaining transaction atomicity is straightforward:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 'alice';
UPDATE accounts SET balance = balance + 100 WHERE id = 'bob';
COMMIT;
The database storage engine locks the rows, appends commit records to the write-ahead log (WAL), and guarantees Atomicity, Consistency, Isolation, and Durability (ACID). If the database crashes mid-flight, recovery rollback routines clean up uncommitted changes.
In modern distributed systems, data is fragmented across microservices, separate database engines, and third-party SaaS APIs:
- An e-commerce checkout must:
- Deduct customer loyalty credits in PostgreSQL.
- Reserve warehouse inventory in a MySQL shard.
- Authorize payment through Stripe’s external HTTP API.
- Generate a shipping label via FedEx.
If Stripe succeeds but FedEx rejects the shipping address, you cannot simply execute ROLLBACK. Stripe already charged the credit card, and PostgreSQL already committed the loyalty credit deduction!
This lesson explores how distributed systems solve atomic commitments across independent nodes: from the strict locking of Two-Phase Commit (2PC) to the eventual consistency of Sagas and the resource reservations of Try-Confirm-Cancel (TCC).
2. Intuitive Mental Model
Imagine booking a vacation requiring an Airline Ticket, a Hotel Room, and a Rental Car:
- Two-Phase Commit (2PC): You call all three vendors simultaneously. You ask: “Can you hold this seat/room/car?” (Prepare). All three say “Yes” and freeze the reservations. You then tell all three: “Book it!” (Commit). If your phone battery dies while they are on hold, all three vendors refuse to rent those seats/rooms to anyone else, waiting indefinitely for your call (The 2PC Blocking Hazard).
- The Saga Pattern: You book and pay for the flight immediately. Then you book and pay for the hotel. When you try to book the rental car, all cars are sold out! You cannot undo the past, so you execute Compensating Actions: you cancel the hotel (getting a refund) and cancel the flight (paying a cancellation fee).
- Try-Confirm-Cancel (TCC): You call the hotel and ask them to put a $100 pre-authorization hold on your card (Try). You do the same for the car. Once all pre-authorizations succeed, you call them back to finalize the charges (Confirm). If one vendor fails, you call the others to release the holds (Cancel).
1. 2PC Phase 1: Prepare & Lock Acquisition
Coordinator broadcasts PREPARE? to DB Node A and B. Both nodes acquire exclusive row locks in local databases and reply YES.
2. Saga Forward Steps: T1 & T2 Commit Locally
Saga Orchestrator invokes Step 1 (Reserve Seat) and Step 2 (Charge Card). Each step commits immediately in its respective service database without holding cross-service locks.
3. Step T3 Failure & Backward Compensations (C2, C1)
T3 (Book Hotel) fails due to inventory depletion. Orchestrator triggers semantic backward compensations: C2 (Refund Card) and C1 (Release Seat) to restore system consistency.
3. Architectural Mechanisms & Concrete Topologies
3.1 Two-Phase Commit (2PC) Mechanics
2PC coordinates atomic commitment across a Coordinator and Participants:
COORDINATOR PARTICIPANT 1 PARTICIPANT 2
| | |
|---- 1. PREPARE -------------------->| |
|---- 1. PREPARE ------------------------------------------------->|
| | [Locks Rows & Writes WAL] | [Locks Rows & Writes WAL]
|<--- 2. VOTE_COMMIT (YES) -----------| |
|<--- 2. VOTE_COMMIT (YES) ----------------------------------------|
|
[Writes COMMIT to WAL]
|
|---- 3. GLOBAL_COMMIT -------------->| |
|---- 3. GLOBAL_COMMIT ------------------------------------------->|
| | [Releases Locks] | [Releases Locks]
|<--- 4. ACK -------------------------| |
|<--- 4. ACK ------------------------------------------------------|
The Critical Flaw (Blocking State):
If the Coordinator crashes immediately after receiving YES votes but before broadcasting GLOBAL_COMMIT, participants remain locked in the PREPARED state. They cannot release row locks, because they do not know whether the coordinator committed or aborted.
3.2 Saga Patterns: Orchestration vs. Choreography
A Saga is a sequence of local transactions with corresponding compensating transactions .
- Choreography (Decentralized Events): Each service executes , commits locally, and emits a Kafka event. The next service listens to the event and executes . If failure occurs, compensating events are emitted backwards. Problem: Circular event dependencies and difficulty visualizing global workflow state.
- Orchestration (Centralized State Machine): A dedicated orchestrator (e.g. Temporal, AWS Step Functions) coordinates the sequence, explicitly calling RPC endpoints for and invoking if any step fails.
4. Formal Mathematical Derivations
4.1 Latency Cost of 2PC vs. Local Commit
Let network round-trip time between coordinator and participants be . Let local disk fsync time for the transaction log be .
In a standard single-node transaction:
In a distributed 2PC transaction across participants in parallel:
Assuming and : Distributed 2PC introduces a end-to-end latency penalty compared to local commits while holding contended row locks across participants, capping per-key throughput via Little’s Law:
4.2 Availability Multiplication Penalty: Synchronous 2PC vs. Durable Sagas
In a synchronous 2PC transaction spanning independent microservice databases and 1 coordinator—each with independent steady-state availability —every participant and the coordinator must be simultaneously reachable for the transaction to commit:
- Worked Example: Across services (Loyalty, Inventory, Payments, Shipping) plus 1 Coordinator, each boasting () uptime:
- By contrast, an asynchronous Saga persisted in a replicated durable workflow log (
Temporal,Apache Kafka) decouples temporal availability: order acceptance requires only the durable orchestrator (), while transient downstream service restarts are absorbed via exponential backoff retries or clean backward compensation.
5. Edge Cases, Failure Modes & Distributed Anomalies
5.1 The Semantic Anomaly of Sagas: Lack of Isolation
The fundamental compromise of the Saga pattern is that Sagas provide Atomicity, Consistency, and Durability, but NOT Isolation (ACD without I)! Because Step commits immediately to its local database:
- Dirty Reads: Another user can read the intermediate state of before the Saga completes.
- If Step subsequently fails and is executed to rollback , the external user has already acted upon data that was retroactively cancelled!
5.2 Out-of-Order Compensations
In an asynchronous network, a compensating transaction might arrive at a service before the forward transaction due to network routing delays! If the service executes (e.g. refund $50) on a non-existent account record, and (charge $50) arrives 2 seconds later, the user keeps the $50 charge and the refund is lost. Remediation: The service must record a “pre-cancellation tombstone” when arrives first, so that when eventually arrives, it is immediately rejected as a no-op.
6. Open-Source Architectural Studies
6.1 CockroachDB Parallel Commits & TiDB Async Commit over Multi-Raft
Distributed SQL databases eliminate the single-node 2PC coordinator bottleneck by storing both transaction status records and row-level lock intents inside Multi-Raft consensus groups:
- CockroachDB Parallel Commits: The transaction coordinator writes provisional write intents to participant ranges and simultaneously writes a
STAGINGtransaction record to the coordinator’s Raft group in parallel. Once both acknowledgments return to the client, the transaction is implicitly committed in , and theSTAGINGrecord is asynchronously promoted toCOMMITTED. - TiDB / TiKV Percolator Async Commit & 1PC: TiKV builds on Google Percolator’s primary-lock 2PC model (designating one mutated key as the primary lock and pointing secondary locks to it). In TiDB 5.0+, 1PC commits single-Region transactions in one Raft round without 2PC, while Async Commit records all secondary keys on the primary lock during Prewrite so the client can be acknowledged as soon as all Prewrites succeed—cutting commit latency from to .
6.2 Temporal: Durable Execution Orchestrator
Temporal executes Sagas as deterministically replayable Go/TypeScript workflows. Every workflow step is recorded in a durable Cassandra/PostgreSQL execution history. If a worker process dies in the middle of executing a compensation, another worker rehydrates the workflow from the event log and resumes compensation from the exact step of failure.
7. Production Incident Post-Mortem (War Stories)
The 2PC Frozen Cluster Disaster
Context: A banking platform used an XA-compliant 2PC distributed transaction manager across Oracle and DB2 databases.
The Incident: A high-voltage power surge in the primary data center killed the transaction coordinator server mid-transaction.
The Impact: 85 high-volume customer accounts were left in the PREPARED state on the surviving Oracle database. The exclusive table locks blocked all subsequent deposit and withdrawal attempts for those accounts. Because the coordinator server’s local disk was fried, automated recovery could not determine if the transaction had committed or aborted.
The Resolution: Database administrators spent 6 hours manually inspecting physical transaction logs and issuing manual COMMIT PREPARED and ROLLBACK PREPARED SQL statements.
The Architectural Lesson: Never use blocking 2PC across independent microservices; replace synchronous locks with asynchronous Sagas or TCC resource reservations.
8. Production-Grade TypeScript Simulation: Distributed Saga Orchestrator
This simulation implements a complete state-machine Saga Orchestrator executing multi-step forward transactions with automatic, idempotent backward compensation on failure.
/**
* Production Saga Orchestration Simulation with Compensating Workflows
*/
export interface SagaStep<TContext> {
name: string;
execute: (context: TContext) => Promise<void>;
compensate: (context: TContext) => Promise<void>;
}
export enum SagaStatus {
NOT_STARTED = "NOT_STARTED",
IN_PROGRESS = "IN_PROGRESS",
COMPLETED = "COMPLETED",
COMPENSATING = "COMPENSATING",
COMPENSATED = "COMPENSATED",
FAILED = "FAILED",
}
export class SagaOrchestrator<TContext> {
private steps: SagaStep<TContext>[] = [];
public status: SagaStatus = SagaStatus.NOT_STARTED;
public executionLog: string[] = [];
public addStep(step: SagaStep<TContext>): this {
this.steps.push(step);
return this;
}
public async execute(context: TContext): Promise<boolean> {
this.status = SagaStatus.IN_PROGRESS;
const executedSteps: SagaStep<TContext>[] = [];
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
try {
this.executionLog.push(`[EXECUTE] Step: ${step.name}`);
await step.execute(context);
executedSteps.push(step);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.executionLog.push(`[FAILURE] Step: ${step.name} failed with: ${message}`);
this.status = SagaStatus.COMPENSATING;
await this.rollback(executedSteps, context);
this.status = SagaStatus.COMPENSATED;
return false;
}
}
this.status = SagaStatus.COMPLETED;
this.executionLog.push(`[SUCCESS] Saga completed successfully`);
return true;
}
private async rollback(executedSteps: SagaStep<TContext>[], context: TContext): Promise<void> {
// Compensate in reverse order
for (let i = executedSteps.length - 1; i >= 0; i--) {
const step = executedSteps[i];
try {
this.executionLog.push(`[COMPENSATE] Rolling back step: ${step.name}`);
await step.compensate(context);
} catch (compErr: unknown) {
const message = compErr instanceof Error ? compErr.message : String(compErr);
this.executionLog.push(
`[CRITICAL] Compensation failed for ${step.name}: ${message}. Requires manual triage!`
);
}
}
}
}
// Verification Routine
export async function runSagaSimulation() {
console.log("=== Distributed Saga Orchestration Simulation ===");
interface OrderContext {
orderId: string;
userId: string;
totalAmount: number;
inventoryReserved: boolean;
paymentCharged: boolean;
shippingScheduled: boolean;
}
const context: OrderContext = {
orderId: "ord_999",
userId: "usr_42",
totalAmount: 150,
inventoryReserved: false,
paymentCharged: false,
shippingScheduled: false,
};
const saga = new SagaOrchestrator<OrderContext>();
// Step 1: Inventory
saga.addStep({
name: "ReserveInventory",
execute: async (ctx) => {
ctx.inventoryReserved = true;
},
compensate: async (ctx) => {
ctx.inventoryReserved = false;
},
});
// Step 2: Payment
saga.addStep({
name: "ChargePayment",
execute: async (ctx) => {
ctx.paymentCharged = true;
},
compensate: async (ctx) => {
ctx.paymentCharged = false;
},
});
// Step 3: Shipping (Simulate intentional third-party failure)
saga.addStep({
name: "ScheduleShipping",
execute: async () => {
throw new Error("Carrier address verification rejected: invalid postal code");
},
compensate: async (ctx) => {
ctx.shippingScheduled = false;
},
});
const success = await saga.execute(context);
console.log(`Saga Succeeded: ${success}`);
console.log(`Final Saga Status: ${saga.status}`);
console.log("Execution Trace:");
for (const log of saga.executionLog) {
console.log(` ${log}`);
}
console.log("Final Context State:", context);
}
runSagaSimulation();
9. Operational Checklist, Failure Runbooks & Protocol Matrix
Distributed Transaction Comparison Matrix
| Property | Two-Phase Commit (2PC) | Three-Phase Commit (3PC) | Saga Orchestration | Try-Confirm-Cancel (TCC) |
|---|---|---|---|---|
| ACID Isolation | Full Serializable Isolation | Full Isolation | None (Dirty Reads possible) | Application-Level Isolation |
| Blocking on Crash | Yes (Coordinator crash halts nodes) | No (assumes synchronous net) | No (Asynchronous recovery) | No (Timeout releases holds) |
| Throughput / Latency | Low / High Latency () | Low / Very High () | High / Low Latency | High / Moderate Latency |
| Suitability | Sharded SQL over Raft/Paxos | Theoretical synchronous LAN | Long-running microservice flows | Low-latency financial reservations |
10. Guided Applied System Design Exercises & Debugging Drills
Drill 1: Designing a Hotel Booking TCC Engine
Design a Try-Confirm-Cancel protocol for an online travel agency reserving rooms across 15 hotel partner APIs.
- Define the exact state machine transitions for the room entity in the partner’s database (
AVAILABLEHELD_FOR_TRYCONFIRMED). - What expiration mechanism ensures that a crashed client does not hold hotel rooms indefinitely?
Drill 2: Resolving Saga Dirty Read Anomalies
A user initiates a flight transfer. The Saga deducts $500 from Account A, and prepares to credit $500 to Account B. In the 2 seconds before Account B is credited, Account A’s mobile app queries its balance and displays the $500 deduction. A sudden network failure causes Account B’s credit to abort, and compensation refunds Account A.
- How do you design the user interface and database schema (e.g. pending balances) to prevent user panic over transient intermediate states?
View Reference Architecture & Worked Solutions (CORE-M22-001)
1. Worked Solution — Drill 1: Designing a Hotel Booking TCC Engine
- Step 1 — Partner Room Entity State Machine & Idempotency Table:
- Maintain an explicit
room_reservationstable alongside the inventory counter soTry,Confirm, andCancelare strictly idempotent and safe against network reordering:CREATE TABLE room_inventory ( hotel_id UUID, room_type TEXT, date DATE, available_count INT NOT NULL CHECK (available_count >= 0), held_count INT NOT NULL CHECK (held_count >= 0), confirmed_count INT NOT NULL CHECK (confirmed_count >= 0), PRIMARY KEY (hotel_id, room_type, date) ); CREATE TABLE tcc_reservation_holds ( tx_id UUID PRIMARY KEY, hotel_id UUID NOT NULL, state TEXT NOT NULL CHECK (state IN ('HELD_FOR_TRY', 'CONFIRMED', 'CANCELLED', 'EXPIRED')), expires_at TIMESTAMPTZ NOT NULL ); - State Transitions:
Try(tx_id, ttl=120s): In a single local ACID transaction, check iftx_idalready exists intcc_reservation_holds(if'CANCELLED', reject as an out-of-order late Try). Otherwise decrementavailable_count = available_count - 1, incrementheld_count = held_count + 1, and insert('HELD_FOR_TRY', NOW() + INTERVAL '120 seconds').Confirm(tx_id): If state is'HELD_FOR_TRY', atomically transition to'CONFIRMED', decrementheld_count, and incrementconfirmed_count. (If already'CONFIRMED', return200 OKidempotently).Cancel(tx_id): If state is'HELD_FOR_TRY', transition to'CANCELLED', decrementheld_count, and restoreavailable_count = available_count + 1. Iftx_idhas not arrived yet, insert a pre-cancellation tombstone (state = 'CANCELLED') so a delayedTrypacket cannot leak a hold.
- Maintain an explicit
- Step 2 — Lease Expiration Reaper for Crashed Clients:
- Every
HELD_FOR_TRYrow carries a bounded leaseexpires_at = NOW() + 120s. A background reaper (or lazy read-time check) scansWHERE state = 'HELD_FOR_TRY' AND expires_at < NOW() FOR UPDATE SKIP LOCKED, transitions expired holds to'EXPIRED', and returnsheld_countback toavailable_count, guaranteeing non-blocking recovery if the travel agency orchestrator crashes.
- Every
2. Worked Solution — Drill 2: Resolving Saga Dirty Read Anomalies (available_balance vs. pending_debit)
- Step 1 — Semantic Lock Schema Design (Dual-Balance Accounting):
- Never overwrite a single
balancecolumn directly in Step 1 of an unisolated Saga. Instead, split the account state intosettled_balance,pending_debit, andpending_credit, plus atransfer_statusledger: - Forward Step (Debit Account A $500):
- Validates
settled_balance - pending_debit >= 500, incrementspending_debit += 500, and records ledger row(tx_id, status = 'IN_FLIGHT_OUTBOUND', amount = 500).
- Validates
- Forward Step (Credit Account B $500) + Finalization Step :
- Once Account B confirms receipt, deducts
settled_balance -= 500andpending_debit -= 500on Account A, marking the ledger row'SETTLED'.
- Once Account B confirms receipt, deducts
- Compensating Step (If Account B Credit Aborts):
- Simply decrements
pending_debit -= 500and updates the ledger row to'REVERSED'—Account A’ssettled_balancenever falsely dropped!
- Simply decrements
- Never overwrite a single
- Step 2 — UX Presentation to Prevent User Panic:
- During the 2-second in-flight window, Account A’s mobile app displays:
Settled Balance: $1,200.00 | Pending Transfer: -$500.00 (Processing...) | Available Balance: $700.00. If the transfer fails and compensates, the pending hold disappears cleanly without two jarring balance swings ($1,200 -> $700 -> $1,200) in the settled statement.
- During the 2-second in-flight window, Account A’s mobile app displays:
11. Summary & Architectural Decision Guide
- Use Consensus-Backed 2PC (
CockroachDBParallel Commits,TiDB/TiKVPercolator,Google Spanner) when all participating shards belong to a single unified distributed SQL cluster where coordinators and participants are replicated Raft/Paxos groups rather than single points of failure. - Use Orchestrated Sagas (
Temporal,AWS Step Functions) across autonomous microservices or third-party HTTP APIs (Stripe, FedEx) where holding cross-service locks is impossible; enforce idempotency keys and pre-cancellation tombstones on every forward and compensating handler. - Use Try-Confirm-Cancel (TCC) for high-contention financial wallets or inventory reservations where exposing intermediate uncommitted Saga deductions (
dirty reads) violates business invariants.
12. Authoritative References & Version Context
- Version & Implementation Baseline: Distributed transaction architectures reflect
CockroachDBv23.2(Parallel Commits),TiDB/TiKVv7.5(Percolator-inspired 2PC with Async Commit & 1PC), andTemporal Serverv1.22+. - Gray, J. (1978). Notes on Data Base Operating Systems (§5.7 Two-Phase Commit Protocol). Operating Systems: An Advanced Course, Springer LNCS Vol. 60, 393–481.
- Skeen, D. (1981). Nonblocking Commit Protocols. Proceedings of the 1981 ACM SIGMOD International Conference on Management of Data, 133–142.
- Garcia-Molina, H., & Salem, K. (1987). Sagas. Proceedings of the 1987 ACM SIGMOD International Conference on Management of Data, 249–259.
- Peng, D., & Dabek, F. (2010). Large-scale Incremental Processing Using Distributed Transactions and Notifications (Google Percolator). Proceedings of USENIX OSDI ‘10, 251–264.
- Taft, R., et al. (2020). CockroachDB: The Resilient Geo-Distributed SQL Database (§4.2 Parallel Commits). Proceedings of ACM SIGMOD ‘20, 1493–1509.