How to Implement a Two-Phase Commit Mock in Python

Simulate a distributed two-phase commit with prepare, commit, and abort phases, including deterministic failure injection for testing.

Medium Python 3.10+ Aug 9, 2026 Microservices patterns 13 views 0 copies

Python code

70 lines
Python 3.10+
import random
from dataclasses import dataclass
from typing import Dict, List, Optional


@dataclass
class Transaction:
    tx_id: int
    data: Dict[str, str]


class TwoPhaseCommitMock:
    """Simple two-phase commit mock with prepare and commit phases."""

    def __init__(self) -> None:
        self.prepared: List[int] = []
        self.committed: Dict[int, Transaction] = {}
        self.fail_on_prepare: set[int] = set()
        self.fail_on_commit: set[int] = set()

    def prepare(self, tx: Transaction) -> bool:
        # Simulate possible failure during prepare phase
        if tx.tx_id in self.fail_on_prepare or random.random() < 0.1:
            return False
        self.prepared.append(tx.tx_id)
        return True

    def commit(self, tx: Transaction) -> bool:
        if tx.tx_id not in self.prepared:
            print(f"Transaction {tx.tx_id} not prepared, cannot commit")
            return False
        if tx.tx_id in self.fail_on_commit:
            return False
        self.committed[tx.tx_id] = tx
        return True

    def abort(self, tx: Transaction) -> None:
        if tx.tx_id in self.prepared:
            self.prepared.remove(tx.tx_id)
            print(f"Transaction {tx.tx_id} aborted")

    def run_transaction(self, tx: Transaction) -> bool:
        """Execute a full two-phase commit transaction."""
        if not self.prepare(tx):
            print(f"Prepare failed for transaction {tx.tx_id}, aborting")
            self.abort(tx)
            return False
        if not self.commit(tx):
            print(f"Commit failed for transaction {tx.tx_id}, aborting")
            self.abort(tx)
            return False
        print(f"Transaction {tx.tx_id} committed successfully")
        return True


if __name__ == "__main__":
    # Deterministic mock for demonstration
    mock = TwoPhaseCommitMock()
    mock.fail_on_prepare = {2}  # Always fail on transaction 2
    mock.fail_on_commit = {3}   # Always fail on commit for transaction 3

    t1 = Transaction(1, {"account": "A", "amount": "100"})
    t2 = Transaction(2, {"account": "B", "amount": "50"})
    t3 = Transaction(3, {"account": "C", "amount": "75"})

    for tx in [t1, t2, t3]:
        mock.run_transaction(tx)

    print(f"\nCommitted transactions: {sorted(mock.committed.keys())}")
    print(f"Prepared (but maybe uncommitted): {mock.prepared}")

Output

stdout
Prepare failed for transaction 2, aborting
Transaction 2 aborted
Commit failed for transaction 3, aborting
Transaction 3 aborted
Transaction 1 committed successfully

Committed transactions: [1]
Prepared (but maybe uncommitted): []

How it works

The TwoPhaseCommitMock wraps the coordinator logic for a simplified 2PC: each transaction first goes through prepare, then commit, with abort as a fallback for either failure. fail_on_prepare and fail_on_commit sets let you inject deterministic failures, while random.random() < 0.1 adds stochastic failure for realistic testing. The run_transaction method orchestrates the whole flow, ensuring a transaction is never committed unless it was successfully prepared first. This mirrors the core guarantee of two-phase commit — atomicity across participants — without needing real network or database resources.

Common mistakes

  • Forgetting to check membership in `prepared` before allowing a commit, breaking the prepare-then-commit invariant.
  • Using mutable class-level attributes for state, causing cross-instance contamination when multiple mocks exist.
  • Not providing a way to simulate failures, making it impossible to test abort and rollback logic.
  • Ignoring stochatic failure in tests, leading to flaky assertions when randomness is not seeded.

Variations

  1. Use a state machine (e.g., `enum` with PREPARING, PREPARED, COMMITTED) to enforce phase transitions more strictly.
  2. Replace the dataclass with a generic `Transaction` protocol so it can wrap different payload types in real services.

Real-world use cases

  • Unit-testing a transaction coordinator's retry and rollback logic without spinning up multiple database connections.
  • Simulating network partitions or node crashes in a microservices integration test to verify the saga compensates correctly.
  • Teaching or demoing the 2PC protocol in a classroom or onboarding session with deterministic, reproducible failures.

Sponsored

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Microservices patterns

Related tutorials and quizzes for this topic.