7. Distributed Transactions

Two-Phase Commit

seniordistributed-systemsconsistency-models

Two-phase commit (2PC) is the classic protocol for a transaction spanning multiple participants (databases, services) where ALL of them must agree before any of them actually commits. Phase 1: ask everyone to vote. Phase 2: if everyone voted yes, tell everyone to commit; if even ONE voted no, tell EVERYONE to abort -- including the participants that voted yes. A transaction can never be half-committed.

Participant (given) stands in for a real database/service: vote() reports whether it CAN commit; commit()/abort() are what the coordinator tells it to actually do.

The missing link: the coordinator can crash too

Phase 1 and phase 2 aren't instantaneous -- there's a real gap between "decided COMMIT" and "told every participant." If the coordinator crashes in that gap, participants that already voted yes are stuck holding resources indefinitely, unable to tell whether they should commit or abort on their own (a YES vote is a promise -- "I CAN commit if told to," not permission to decide for itself). The fix is a durable decision log: the coordinator writes its decision down BEFORE notifying anyone, so that after a restart it can look at what it already logged and finish the job. Critically, recovery must never re-run phase 1 -- once a decision is logged, it's final, and asking participants to vote again could get a different answer the second time (a participant that could commit a minute ago might not be able to now).

Implement:

  • Coordinator.run(participants): ask every participant to vote (phase 1). Decide "COMMIT" if ALL votes are truthy, else "ABORT". Append that decision to self.log BEFORE calling .commit()/.abort() on anyone. Then notify every participant to match the decision, and return True for COMMIT, False for ABORT.
  • Coordinator.recover(participants): simulates resuming after a crash. Replay the LAST decision in self.log to every participant -- WITHOUT calling .vote() on any of them. Raise RuntimeError if self.log is empty (nothing was ever decided).

two_phase_commit(participants) (given) is the simple one-shot entry point for callers that don't need crash recovery -- it just runs a fresh Coordinator.

Example

coordinator = Coordinator()
coordinator.run([Participant(True), Participant(False)])  # -> False, logs "ABORT"

# ... coordinator process crashes and restarts here ...

fresh_participants = [Participant(True)]  # freshly reconnected, haven't voted
coordinator.recover(fresh_participants)     # replays "ABORT" -- no re-vote

Constraints

  • Collect all votes BEFORE deciding commit vs. abort -- don't commit participants one at a time as their yes votes come in, since a later NO could still force a global abort.
  • The decision must be appended to self.log before ANY participant is notified in run -- not after, and not interleaved with notification.
  • recover must not call .vote() on any participant -- it only replays the already-decided outcome.

Real-world grounding

2PC (and its more fault-tolerant descendant, Paxos/Raft-backed atomic commit) underlies distributed transactions in systems like Google Spanner and CockroachDB's transaction coordinator. Real coordinators persist the decision to a write-ahead log on durable storage (not an in-memory list, as here) for exactly the reason this exercise models: the log has to survive the crash that made recovery necessary in the first place.

hints
two_phase_commit.pysign in to run
Loading...
test result

This is a preview. Sign in to run this exercise against the real tests.