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 toself.logBEFORE calling.commit()/.abort()on anyone. Then notify every participant to match the decision, and returnTruefor COMMIT,Falsefor ABORT.Coordinator.recover(participants): simulates resuming after a crash. Replay the LAST decision inself.logto every participant -- WITHOUT calling.vote()on any of them. RaiseRuntimeErrorifself.logis 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.logbefore ANY participant is notified inrun-- not after, and not interleaved with notification. recovermust 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.