Simulate Two-Phase Commit Failures
Build a Python simulator for two-phase commit failures and recovery.
Introduction
30 Second Summary
A bank transfer can look complete in one account while silently failing in the other. That split result leaves the records out of balance.
In this project, you will build a terminal-based bank transfer simulator in Python. The simulator reveals how two-phase commit preserves a decision while a coordinator failure blocks prepared participants.
What You'll Build
Your terminal will trace a $30 transfer from the first vote through a coordinator crash to a safe recovery from a saved decision.
By the end of this project, you'll have:
- Expose a broken transfer where account A ends at A=70. Account B stays at B=50. The broken total becomes total=120.
- Run a successful two-phase commit that moves both participants through PREPARED to COMMITTED. The final balance returns to total=150.
- Recover a crashed coordinator from a readable JSON decision log. Both participants finish the transfer using the saved result.
- Secret Mission: Force account B to vote NO. Prove that a global abort leaves A=100, B=50, and total=150.
Are there any prerequisites?
Basic familiarity with Python classes, methods, lists, and exception handling will help you follow the simulator code.
You also need Python plus Visual Studio Code installed on Windows.
Before We Start
Before any code exists, the simulator needs a clear safety rule for a $30 transfer: both account participants update or neither does. Its coordinator crash must expose two-phase commit blocking behavior.
Set Up the Simulator Workspace
The $30 transfer needs a stable local home before its failure can be reproduced. A dedicated workspace keeps every simulator run tied to the same file.
This step connects your saved simulator file to Python through Visual Studio Code. No package installation is needed.
In this step, get ready to:
- Create the local simulator workspace in Visual Studio Code.
- Verify that the installed runtime uses Python 3.
- Run the starter simulator from the integrated terminal.
Create the simulator workspace
A Visual Studio Code workspace treats one folder as the home for your project. Its Explorer keeps the simulator file visible while you work.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code in the search field. Press Enter to open it.
You should see Visual Studio Code open with its editor area ready for a folder.
- Click File in the top menu.
- Click Open Folder....
- Select Desktop in the folder picker.
- Click New Folder.
- Enter two-phase-commit-lab as the folder name.
- Press Enter to create the folder.
- Select the two-phase-commit-lab folder.
- Click Select Folder.
- Click Yes, I trust the authors if Visual Studio Code displays the Workspace Trust prompt.
You should see two-phase-commit-lab at the top of the Explorer view. The folder is now the local home for your simulator.
- Select the Explorer view in the Activity Bar.
- Click New File... in the Explorer toolbar.
- Enter two_phase_commit.py as the file name.
- Press Enter to create the file.
You should see two_phase_commit.py in the Explorer. The empty file should also be open in the editor.
Verify the Python runtime
The integrated terminal runs commands from the open two-phase-commit-lab folder. A version check confirms whether the default runtime can run this project.
- Click Terminal in the top menu.
- Click New Terminal.
You should see the terminal panel at the bottom of Visual Studio Code. Its prompt should point to the open two-phase-commit-lab folder.
- Check the installed Python version by running this command:
python --version
What does this command confirm?
The --version option prints the version number before Python exits. Output beginning with Python 3 confirms that the required runtime is available.
- Choose the tab that matches your terminal output.
✔️ I see Python 3
That runtime check is complete. Your terminal can use Python 3 for the simulator.
- Continue to the next substep with the terminal open.
ⓧ I see Python 2
This output shows that the default command reaches an older runtime. The simulator requires Python 3.
- Install a current Python runtime by following the official Python guide for Windows.
- Close Visual Studio Code after the installation finishes.
The installation is complete. A fresh Visual Studio Code process can detect the new runtime.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code in the search field. Press Enter to reopen it.
- Click File in the top menu.
- Click Open Folder....
- Select the two-phase-commit-lab folder on your Desktop.
- Click Select Folder.
- Click Terminal in the top menu.
- Click New Terminal.
- Check the replacement runtime by running this command:
python --version
What should change?
The fresh terminal should print a version beginning with Python 3. This confirms that the default command reaches the required runtime.
Still seeing Python 2?
An older installation may still control the default Python command. Follow the troubleshooting section in the official Windows guide.
Help me make the Python command use Python 3 on Windows.
ⓧ The command is unavailable
Windows cannot currently find a Python runtime through the default command. Installing Python provides the runtime that runs the simulator.
- Install a current Python runtime by following the official Python guide for Windows.
- Close Visual Studio Code after the installation finishes.
The installation is complete. Reopening Visual Studio Code gives its terminal access to the installed runtime.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code in the search field. Press Enter to reopen it.
- Click File in the top menu.
- Click Open Folder....
- Select the two-phase-commit-lab folder on your Desktop.
- Click Select Folder.
- Click Terminal in the top menu.
- Click New Terminal.
- Check the installed runtime by running this command:
python --version
What should change?
The fresh terminal should print a version beginning with Python 3. This confirms that Windows can now find the runtime.
Still unable to run Python?
The installation may need its Windows command shortcuts refreshed. Follow the troubleshooting section in the official Windows guide.
Help me make Python available in Visual Studio Code.
Run the starter script
A readiness message tests the complete path from the saved file to the verified runtime. Seeing it proves that the workspace can execute simulator code.
- Return to the two_phase_commit.py editor tab.
- Add the readiness message by entering this code:
print("2PC simulator ready")
What does this code do?
- The print() function sends a message to the terminal.
- The message confirms that Python found the simulator file.
- The message also confirms that Python executed the file successfully.
- Save two_phase_commit.py with Ctrl+S.
Before you run the script, picture the exact message its only line should print.
- Return to the terminal panel from earlier.
- Start the simulator by running this command:
python two_phase_commit.py
What happens when you run this?
Python executes two_phase_commit.py from the open workspace. You should see 2PC simulator ready on the next terminal line.
Your first simulator run works. The saved project file now executes through the verified Python runtime.
✔️ Awesome, I've got everything!
Your two_phase_commit.py file is saved. The terminal output confirms that the starter simulator runs successfully.
ⓧ I'd like to double check the full code
Compare your two_phase_commit.py file with the complete file for this step.
print("2PC simulator ready")
What should match?
The file contains one line that prints the exact readiness message. Save the file after correcting any differences.
Starter message missing?
- Confirm that the terminal prompt points to the two-phase-commit-lab folder.
- Check that the Explorer shows two_phase_commit.py with the exact file name.
- Save two_phase_commit.py before running the script again.
Help me troubleshoot the missing readiness message.
Your local workspace is ready. Next, you will make the transfer fail without coordination so the atomicity problem becomes visible.
Demonstrate Atomicity Failure
Your simulator workspace now runs from Visual Studio Code. The next test puts the $30 transfer under pressure by letting each account decide locally.
A distributed transaction needs atomicity across every participant. This naive path exposes the failure that appears when participant A commits before participant B rejects.
In this step, get ready to:
- Create readable JSON event logs.
- Model immediate local decisions for both participants.
- Run a $30 transfer that exposes a broken total.
Build the local event log
Each account needs a durable record of its own local choice. EventLog writes those choices into JSON files inside logs.
- In two_phase_commit.py, place the cursor above the existing starter line.
- Paste the logging foundation by adding this code above the starter line:
import json
from pathlib import Path
LOG_DIR = Path("logs")
def reset_logs():
LOG_DIR.mkdir(exist_ok=True)
for log_file in LOG_DIR.glob("*.json"):
log_file.unlink()
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2))
What does this code do?
- The standard library imports provide JSON conversion plus file path handling.
- LOG_DIR points every event log at the local logs folder.
- reset_logs() clears old scenario files before a new run.
- EventLog.append() adds one event to memory.
- write_text() persists the complete event list as readable JSON.
- Save two_phase_commit.py with Cmd+S (macOS) or Ctrl+S (Windows).
- Switch back to the VS Code terminal from earlier.
- Confirm the logging foundation loads by running:
python two_phase_commit.py
You should still see 2PC simulator ready. That familiar message now confirms the logging foundation loads without a syntax problem.
Is the logging foundation failing to load?
- Confirm that import json is the first line in two_phase_commit.py.
- Confirm that from pathlib import Path sits directly below the JSON import.
- Compare the indentation inside reset_logs() with the code block above.
Help me fix the logging foundation in my simulator.
Model independent participant updates
Participant represents one account service with its own balance. Its local update either changes that balance immediately or records a rejection.
- Place the cursor below the EventLog class.
- Add the participant state by pasting this code above the starter print line:
class Participant:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
def apply_without_2pc(self, delta, accepts=True):
print(f"{self.name}: local update requested ({delta:+})")
if not accepts:
self.state = "REJECTED"
self.log.append("LOCAL_REJECT", {"delta": delta})
print(f"{self.name}: REJECTED")
return False
self.balance += delta
self.state = "COMMITTED"
self.log.append("LOCAL_COMMIT", {"delta": delta})
print(f"{self.name}: COMMITTED immediately")
return True
How does a participant decide?
- name identifies the account shown in terminal messages.
- balance holds the participant's visible account balance.
- state begins at INIT before any local choice occurs.
- accepts controls whether the participant applies or rejects its requested update.
- apply_without_2pc() persists each local outcome without waiting for a shared decision.
- Save two_phase_commit.py.
- Switch back to the terminal from earlier.
- Confirm Python can define the participant class by running:
python two_phase_commit.py
You should still see 2PC simulator ready. The unchanged starter message confirms Python can now define Participant successfully.
Is the participant class failing to load?
- Confirm that Participant starts after the complete EventLog class.
- Check that every method inside Participant uses consistent indentation.
- Check that self.log creates an EventLog with the participant name plus transaction ID.
Help me debug my Participant class.
Run the naive transfer
show_balances() makes the transaction invariant visible before each local decision. A correct transfer keeps the combined balance at 150.
run_naive_failure() requests participant A's debit before participant B's credit. Each participant still controls its own local outcome.
- Find print("2PC simulator ready") at the bottom of two_phase_commit.py.
- Delete the starter print line.
- Paste the naive transfer scenario in the same position:
def show_balances(label, participants):
balances = ", ".join(
f"{participant.name}={participant.balance}"
for participant in participants
)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
print("\n=== 1. NAIVE TRANSFER: ATOMICITY BREAKS ===")
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
show_balances("Before", [account_a, account_b])
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
print("Result: A committed alone. The distributed transfer is not atomic.")
if __name__ == "__main__":
run_naive_failure()
What does this scenario test?
- show_balances() calculates the combined total from both participant balances.
- run_naive_failure() resets old logs before creating the two accounts.
- account_a receives a local debit request for -30.
- account_b receives a local credit request for +30.
- The main guard launches the scenario whenever you run the script directly.
- Save two_phase_commit.py.
- Switch back to the terminal from earlier.
Before you run it, consider whether the combined total will stay at 150 or fall below it.
- Reveal the result by running:
python two_phase_commit.py
You should see After: A=70, B=50, total=120. The transfer started with a combined balance of 150.
You have made the atomicity bug visible. The missing $30 proves that independent local outcomes cannot protect the transfer as one unit.
- Expand the generated logs folder in the file tree on the left.
- Confirm that A_naive.json appears in the folder.
- Confirm that B_naive.json appears in the folder.
Not seeing the broken total?
- Confirm that the main guard calls run_naive_failure().
- Check that participant A receives -30 with accepts=True.
- Check that participant B receives 30 with accepts=False.
- Save two_phase_commit.py before running the script again.
Help me find why my naive transfer does not end at A=70, B=50, total=120.
✔️ Awesome, I've got everything!
Your naive failure is working. Double check that two_phase_commit.py is saved before you continue.
ⓧ I'd like to double check the full code
Compare your complete two_phase_commit.py file with this version.
import json
from pathlib import Path
LOG_DIR = Path("logs")
def reset_logs():
LOG_DIR.mkdir(exist_ok=True)
for log_file in LOG_DIR.glob("*.json"):
log_file.unlink()
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2))
class Participant:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
def apply_without_2pc(self, delta, accepts=True):
print(f"{self.name}: local update requested ({delta:+})")
if not accepts:
self.state = "REJECTED"
self.log.append("LOCAL_REJECT", {"delta": delta})
print(f"{self.name}: REJECTED")
return False
self.balance += delta
self.state = "COMMITTED"
self.log.append("LOCAL_COMMIT", {"delta": delta})
print(f"{self.name}: COMMITTED immediately")
return True
def show_balances(label, participants):
balances = ", ".join(
f"{participant.name}={participant.balance}"
for participant in participants
)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
print("\n=== 1. NAIVE TRANSFER: ATOMICITY BREAKS ===")
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
show_balances("Before", [account_a, account_b])
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
print("Result: A committed alone. The distributed transfer is not atomic.")
if __name__ == "__main__":
run_naive_failure()
The atomicity failure is now reproducible through one command. Next, you will replace independent local choices with a coordinated prepare and decision flow.
Build the Two-Phase Commit State Machine
You exposed the exact failure that distributed atomicity prevents. Account A changed while account B stayed at its original balance.
A two-phase commit collects every participant readiness vote before one global decision. The coordinator keeps each proposed balance change pending until every participant has voted.
In this step, get ready to:
- Add durable participant voting states for prepare, commit, and abort.
- Create a coordinator that records one global decision.
- Run a successful transfer that preserves the original total.
Why a state-machine simulator?
A database lab can hide the coordinator logic inside transaction machinery.
This local state machine exposes each vote. It also exposes every balance transition.
Make participant votes durable
A durable log preserves protocol events outside the running objects. Your JSON files become a readable record of each vote and decision.
- In two_phase_commit.py, select the existing EventLog class.
- Replace the selected class with this version:
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
if self.path.exists():
self.events = json.loads(self.path.read_text(encoding="utf-8"))
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2), encoding="utf-8")
print(f" LOG {self.path.name}: {event}")
What does this code do?
- The constructor reloads existing events when its log file already exists.
- The append() method writes the complete event list after every new event.
- The final line exposes each durable write in the terminal.
- Save two_phase_commit.py in the editor.
- Confirm the new log output from the terminal from earlier by running:
python two_phase_commit.py
What does this check?
This runs the existing naive scenario through the updated log class. Each local outcome now prints when its JSON event is saved.
You should see log lines for LOCAL_COMMIT and LOCAL_REJECT. The naive balances should still end at A=70, B=50, and total=120.
Missing the log event lines?
Check that the final print line is indented inside EventLog.append(). Also confirm that you replaced the complete class.
Help me fix the EventLog output.
A prepared participant needs a private place to hold its proposed update. The visible balance stays untouched while pending_delta holds that proposed change.
- In the Participant class, select its existing __init__() method.
- Replace the selected method with this version:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.pending_delta = 0
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
What does this change?
Each participant now starts with no pending update. A successful prepare phase can store a proposed debit or credit there without changing balance.
- In the Participant class, place the cursor directly below apply_without_2pc().
- Add the prepare phase with this method:
def prepare(self, delta, accepts=True):
print(f"{self.name} <- PREPARE ({delta:+})")
if not accepts or self.balance + delta < 0:
self.state = "VOTED_NO"
self.pending_delta = 0
self.log.append("VOTE_NO", {"delta": delta})
print(f"{self.name} -> VOTE_NO")
return False
self.pending_delta = delta
self.state = "PREPARED"
self.log.append("VOTE_YES", {"delta": delta})
print(f"{self.name} -> VOTE_YES; state=PREPARED")
return True
How does prepare work?
- A participant votes no when it rejects the operation.
- A participant also votes no when the proposed update would make its balance negative.
- A yes vote stores the delta and moves the participant to PREPARED.
- The method returns a Boolean vote for the coordinator to collect.
- Save two_phase_commit.py in the editor.
- Check the script for syntax problems by running:
python two_phase_commit.py
What does this run prove?
The existing scenario still reaches its broken total. That result confirms the participant changes load without disrupting the naive demonstration.
You should still see A=70, B=50, and total=120.
The second phase applies one shared outcome. A commit publishes the pending update. An abort discards it.
- Place the cursor directly below prepare() in the Participant class.
- Add the commit transition with this method:
def commit(self):
self.balance += self.pending_delta
self.pending_delta = 0
self.state = "COMMITTED"
self.log.append("COMMIT")
print(f"{self.name} <- COMMIT; state=COMMITTED")
How does commit work?
The participant applies its pending change only after receiving the global commit decision. It then clears the pending value and records its final state.
- Place the cursor directly below commit().
- Add the abort transition with this method:
def abort(self):
self.pending_delta = 0
self.state = "ABORTED"
self.log.append("ABORT")
print(f"{self.name} <- ABORT; state=ABORTED")
How does abort work?
An abort clears the proposed update without touching the visible balance. The participant records ABORTED as its final state.
- Save two_phase_commit.py in the editor.
- Confirm the complete participant class loads by running:
python two_phase_commit.py
What does this check now?
This run confirms that all three new participant transitions are valid Python methods. The original naive path still uses its independent local updates.
You should see the same naive failure and its two durable local events.
Seeing a participant syntax problem?
Make sure prepare(), commit(), and abort() align with apply_without_2pc() inside the Participant class.
Help me fix the Participant methods.
Coordinate one global decision
The coordinator turns separate votes into one authoritative outcome. It records that outcome before telling any participant to finish.
- In two_phase_commit.py, place the cursor between the end of the Participant class and show_balances().
- Add the coordinator class with this code:
class Coordinator:
def __init__(self, transaction_id):
self.transaction_id = transaction_id
self.log = EventLog(f"coordinator_{transaction_id}")
def execute(self, operations):
self.log.append("START")
print("COORDINATOR -> PREPARE")
votes = []
for participant, delta, accepts in operations:
votes.append(participant.prepare(delta, accepts))
decision = "COMMIT" if all(votes) else "ABORT"
self.log.append(f"DECISION_{decision}")
print(f"COORDINATOR: durable decision={decision}")
self._broadcast([participant for participant, _, _ in operations], decision)
self.log.append("COMPLETE")
return decision
@staticmethod
def _broadcast(participants, decision):
print(f"COORDINATOR -> {decision}")
for participant in participants:
if decision == "COMMIT":
participant.commit()
else:
participant.abort()
How does the coordinator work?
- The coordinator records START before requesting votes.
- The execute() method chooses COMMIT only when every vote is true.
- The decision enters the coordinator log before the broadcast begins.
- The _broadcast() method sends the same decision to every participant.
- Save two_phase_commit.py in the editor.
- Confirm the coordinator class loads with the existing scenario by running:
python two_phase_commit.py
What does this check?
The script imports the new coordinator definition before running the naive scenario. Reaching the balance output confirms that the class is placed at the top level with valid indentation.
You should reach the familiar broken total without a syntax error.
Coordinator class not loading?
Check that class Coordinator: begins at the left edge of the file. Keep both methods indented inside that class.
Help me fix the Coordinator class.
Run a successful atomic transfer
The successful scenario uses the same $30 transfer that failed before. Both participants now vote yes before either visible balance changes.
- In two_phase_commit.py, find show_balances() below the coordinator class.
- Replace show_balances() and the existing run_naive_failure() with this code:
def show_balances(label, participants):
balances = ", ".join(f"{participant.name}={participant.balance}" for participant in participants)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
print("\n=== 1. NAIVE TRANSFER: ATOMICITY BREAKS ===")
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
show_balances("Before", [account_a, account_b])
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
What does this preserve?
The balance helper still prints each account and the combined total. The naive scenario remains your baseline for comparing independent updates with coordinated updates.
- Save two_phase_commit.py in the editor.
- Confirm the baseline scenario still fails atomically by running:
python two_phase_commit.py
What should this show?
Only the naive scenario runs at this point. Its final total remains 120 because account B rejects the matching credit.
You should see the naive scenario end with A=70, B=50, and total=120.
- At the bottom of two_phase_commit.py, select the existing main block.
- Replace the selected block with the successful scenario and updated main block:
def run_successful_2pc():
print("\n=== 2. TWO-PHASE COMMIT: SUCCESS ===")
reset_logs()
account_a = Participant("A", 100, "tx-success")
account_b = Participant("B", 50, "tx-success")
coordinator = Coordinator("tx-success")
show_balances("Before", [account_a, account_b])
coordinator.execute([(account_a, -30, True), (account_b, 30, True)])
show_balances("After", [account_a, account_b])
if __name__ == "__main__":
run_naive_failure()
run_successful_2pc()
What does this scenario test?
- Both accounts begin with the same balances as the naive transfer.
- Both operations allow their participants to vote yes.
- The coordinator records the shared decision before it broadcasts commit.
- The main block runs both scenarios for a direct comparison.
- Save two_phase_commit.py in the editor.
Before you run the finished step, predict when the two visible balances change.
- Run both transfer scenarios from the terminal from earlier with this command:
python two_phase_commit.py
How does this verify two-phase commit?
The terminal orders the prepare messages before the global decision. The commit messages follow that durable decision.
The final balances show that both pending updates became visible under the same outcome.
You should see PREPARE followed by two VOTE_YES events. The coordinator then records DECISION_COMMIT before broadcasting COMMIT.
The successful scenario should finish with A=70, B=80, and total=150.
- Expand the logs folder in the file sidebar.
- Select coordinator_tx-success.json.
- Confirm that DECISION_COMMIT appears before COMPLETE.
The JSON log preserves the global decision as a separate event. That durable ordering becomes the foundation for safe recovery.
✔️ Awesome, I've got everything!
Great work. Your coordinator now collects unanimous votes and completes the transfer atomically.
ⓧ I'd like to double check the full code
Compare your saved two_phase_commit.py with the complete file below.
import json
from pathlib import Path
LOG_DIR = Path("logs")
def reset_logs():
LOG_DIR.mkdir(exist_ok=True)
for log_file in LOG_DIR.glob("*.json"):
log_file.unlink()
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
if self.path.exists():
self.events = json.loads(self.path.read_text(encoding="utf-8"))
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2), encoding="utf-8")
print(f" LOG {self.path.name}: {event}")
class Participant:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.pending_delta = 0
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
def apply_without_2pc(self, delta, accepts=True):
print(f"{self.name}: local update requested ({delta:+})")
if not accepts:
self.state = "REJECTED"
self.log.append("LOCAL_REJECT", {"delta": delta})
print(f"{self.name}: REJECTED")
return False
self.balance += delta
self.state = "COMMITTED"
self.log.append("LOCAL_COMMIT", {"delta": delta})
print(f"{self.name}: COMMITTED immediately")
return True
def prepare(self, delta, accepts=True):
print(f"{self.name} <- PREPARE ({delta:+})")
if not accepts or self.balance + delta < 0:
self.state = "VOTED_NO"
self.pending_delta = 0
self.log.append("VOTE_NO", {"delta": delta})
print(f"{self.name} -> VOTE_NO")
return False
self.pending_delta = delta
self.state = "PREPARED"
self.log.append("VOTE_YES", {"delta": delta})
print(f"{self.name} -> VOTE_YES; state=PREPARED")
return True
def commit(self):
self.balance += self.pending_delta
self.pending_delta = 0
self.state = "COMMITTED"
self.log.append("COMMIT")
print(f"{self.name} <- COMMIT; state=COMMITTED")
def abort(self):
self.pending_delta = 0
self.state = "ABORTED"
self.log.append("ABORT")
print(f"{self.name} <- ABORT; state=ABORTED")
class Coordinator:
def __init__(self, transaction_id):
self.transaction_id = transaction_id
self.log = EventLog(f"coordinator_{transaction_id}")
def execute(self, operations):
self.log.append("START")
print("COORDINATOR -> PREPARE")
votes = []
for participant, delta, accepts in operations:
votes.append(participant.prepare(delta, accepts))
decision = "COMMIT" if all(votes) else "ABORT"
self.log.append(f"DECISION_{decision}")
print(f"COORDINATOR: durable decision={decision}")
self._broadcast([participant for participant, _, _ in operations], decision)
self.log.append("COMPLETE")
return decision
@staticmethod
def _broadcast(participants, decision):
print(f"COORDINATOR -> {decision}")
for participant in participants:
if decision == "COMMIT":
participant.commit()
else:
participant.abort()
def show_balances(label, participants):
balances = ", ".join(f"{participant.name}={participant.balance}" for participant in participants)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
print("\n=== 1. NAIVE TRANSFER: ATOMICITY BREAKS ===")
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
show_balances("Before", [account_a, account_b])
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
def run_successful_2pc():
print("\n=== 2. TWO-PHASE COMMIT: SUCCESS ===")
reset_logs()
account_a = Participant("A", 100, "tx-success")
account_b = Participant("B", 50, "tx-success")
coordinator = Coordinator("tx-success")
show_balances("Before", [account_a, account_b])
coordinator.execute([(account_a, -30, True), (account_b, 30, True)])
show_balances("After", [account_a, account_b])
if __name__ == "__main__":
run_naive_failure()
run_successful_2pc()
Missing votes or seeing the wrong total?
Check that both tuples in run_successful_2pc() end with True. Confirm that the debit is -30 and the credit is 30.
Help me debug the successful two-phase commit scenario.
Your simulator now preserves atomicity through unanimous voting and one durable decision. Next, you will crash the coordinator after that decision and expose the blocking behavior of prepared participants.
Simulate a Coordinator Crash
Your successful two-phase commit now records a decision before changing either balance. That ordering gives the coordinator one authoritative outcome for the transaction.
A failure injector lets you interrupt the coordinator between recording that outcome and broadcasting it. What can each prepared participant safely do when the coordinator disappears at that exact moment?
In this step, get ready to:
- Preserve the transaction evidence in the event log.
- Interrupt the coordinator after its durable decision.
- Inspect the participant states after the interruption.
Preserve the durable evidence
The failure experiment depends on a readable event history. The event log stores each event with optional details inside local JSON files.
- In two_phase_commit.py, select the existing EventLog class.
- Replace the selected class with this code:
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
if self.path.exists():
self.events = json.loads(self.path.read_text(encoding="utf-8"))
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2), encoding="utf-8")
print(f" LOG {self.path.name}: {event}")
What does this class preserve?
- The constructor reloads existing events when a matching log file exists.
- The append() method stores each event before reporting it in the terminal.
- The optional details object records information such as participant names or proposed balance changes.
- Save two_phase_commit.py.
- Confirm the existing scenarios still write their event logs by running:
python two_phase_commit.py
What does this check prove?
The command runs the existing naive transfer and successful transaction. A final total of 150 for the successful transaction confirms that event logging still works without changing the protocol result.
You should see the successful transaction finish with A=70, B=80, total=150.
Did an existing scenario stop running?
Check that the replacement starts at class EventLog:. Make sure the following Participant class remains in the file.
Confirm that every method inside EventLog uses four spaces of indentation.
Ask for help with the event log replacement.
Create the crash boundary
A participant may apply its pending change only after reaching PREPARED. A state guard makes any illegal commit attempt fail visibly.
- In the Participant class, select the existing commit() method.
- Replace the selected method with this guarded version:
def commit(self):
if self.state != "PREPARED":
raise RuntimeError(f"{self.name} cannot commit from state {self.state}")
self.balance += self.pending_delta
self.pending_delta = 0
self.state = "COMMITTED"
self.log.append("COMMIT")
print(f"{self.name} <- COMMIT; state=COMMITTED")
What does the state guard protect?
- The guard accepts a commit only when the participant has already voted yes.
- A valid commit applies pending_delta exactly once.
- The final state records that the participant received the global decision.
- Save two_phase_commit.py.
- Confirm the valid commit path still passes the new guard by running:
python two_phase_commit.py
What does this run check?
The successful scenario prepares both participants before broadcasting the commit decision. Both participants should pass the new state guard.
You should still see both participants enter COMMITTED in the successful scenario.
The injected failure needs a dedicated signal that the scenario can catch. This keeps the simulated coordinator failure separate from unexpected programming errors.
- Add this exception class directly above the existing Coordinator class:
class CoordinatorCrash(Exception):
pass
Why use a dedicated exception?
The CoordinatorCrash exception identifies the deliberate failure point. The scenario can catch this specific interruption without hiding unrelated errors.
- Inside the Coordinator class, select the existing execute() method.
- Replace the selected method with this version:
def execute(self, operations, crash_after_decision=False):
participant_names = [participant.name for participant, _, _ in operations]
self.log.append("START", {"participants": participant_names})
print("COORDINATOR -> PREPARE")
votes = []
for participant, delta, accepts in operations:
votes.append(participant.prepare(delta, accepts))
decision = "COMMIT" if all(votes) else "ABORT"
self.log.append(f"DECISION_{decision}")
print(f"COORDINATOR: durable decision={decision}")
if crash_after_decision:
print("COORDINATOR CRASHES before broadcasting the decision")
raise CoordinatorCrash("simulated coordinator failure")
self._broadcast([participant for participant, _, _ in operations], decision)
self.log.append("COMPLETE")
return decision
Where does the crash happen?
- The coordinator collects every participant vote before choosing the global decision.
- The coordinator appends DECISION_COMMIT before checking the failure switch.
- The enabled switch raises CoordinatorCrash before _broadcast() can deliver the decision.
- Save two_phase_commit.py.
- Verify the default switch preserves the existing scenarios by running:
python two_phase_commit.py
Why do the existing scenarios still finish?
Existing calls use the default value of False for crash_after_decision. The coordinator therefore reaches _broadcast() as before.
You should see the naive failure and successful transaction complete without an injected crash.
Does the coordinator stop too early?
Check that crash_after_decision defaults to False. Confirm that the exception is raised only inside its matching condition.
Ask for help with the coordinator failure switch.
Run the blocked transaction
The terminal becomes easier to compare when the earlier scenarios report only their final balances. Their protocol behavior remains unchanged.
- Select the existing run_naive_failure() function through the end of run_successful_2pc().
- Replace the selected functions with these concise versions:
def run_naive_failure():
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
def run_successful_2pc():
reset_logs()
account_a = Participant("A", 100, "tx-success")
account_b = Participant("B", 50, "tx-success")
Coordinator("tx-success").execute([(account_a, -30, True), (account_b, 30, True)])
show_balances("After", [account_a, account_b])
What stays the same?
The naive scenario still commits account A before account B rejects its update. The successful scenario still prepares both accounts before committing them.
Each function now keeps the terminal focused on the protocol events and final balances.
- Save two_phase_commit.py.
- Confirm both earlier outcomes remain runnable by running:
python two_phase_commit.py
What should remain unchanged?
The naive scenario should finish with a broken total of 120. The successful scenario should finish with a preserved total of 150.
The new scenario creates two fresh participants for transaction tx-crash. Its exception handler reports their states before any later recovery occurs.
- Add this function below run_successful_2pc():
def run_crash_and_recovery():
print("\n=== 3. COORDINATOR CRASH AND RECOVERY ===")
reset_logs()
account_a = Participant("A", 100, "tx-crash")
account_b = Participant("B", 50, "tx-crash")
coordinator = Coordinator("tx-crash")
participants = [account_a, account_b]
show_balances("Before", participants)
try:
coordinator.execute([(account_a, -30, True), (account_b, 30, True)], crash_after_decision=True)
except CoordinatorCrash:
print("Participants are blocked:", account_a.state, account_b.state)
show_balances("While blocked", participants)
How does the scenario capture the failure?
- Both participants begin with balances totalling 150.
- The coordinator runs with the failure switch enabled.
- The exception handler prints the participant states after the interruption.
- The final balance report shows whether any pending update became visible.
- Select the existing if __name__ == "__main__": block.
- Replace the selected block with this version:
if __name__ == "__main__":
run_naive_failure()
run_successful_2pc()
run_crash_and_recovery()
Why add the third function here?
The main block now runs all three protocol demonstrations in sequence. The crash scenario runs last so its logs remain available for inspection.
- Save two_phase_commit.py.
✔️ Awesome, I've got everything!
Your crash scenario is wired into the simulator. Double check that you saved two_phase_commit.py.
ⓧ I'd like to double check the full code
Compare your complete two_phase_commit.py file with this reference.
import json
from pathlib import Path
LOG_DIR = Path("logs")
def reset_logs():
LOG_DIR.mkdir(exist_ok=True)
for log_file in LOG_DIR.glob("*.json"):
log_file.unlink()
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
if self.path.exists():
self.events = json.loads(self.path.read_text(encoding="utf-8"))
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(json.dumps(self.events, indent=2), encoding="utf-8")
print(f" LOG {self.path.name}: {event}")
class Participant:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.pending_delta = 0
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
def apply_without_2pc(self, delta, accepts=True):
print(f"{self.name}: local update requested ({delta:+})")
if not accepts:
self.state = "REJECTED"
self.log.append("LOCAL_REJECT", {"delta": delta})
print(f"{self.name}: REJECTED")
return False
self.balance += delta
self.state = "COMMITTED"
self.log.append("LOCAL_COMMIT", {"delta": delta})
print(f"{self.name}: COMMITTED immediately")
return True
def prepare(self, delta, accepts=True):
print(f"{self.name} <- PREPARE ({delta:+})")
if not accepts or self.balance + delta < 0:
self.state = "VOTED_NO"
self.pending_delta = 0
self.log.append("VOTE_NO", {"delta": delta})
print(f"{self.name} -> VOTE_NO")
return False
self.pending_delta = delta
self.state = "PREPARED"
self.log.append("VOTE_YES", {"delta": delta})
print(f"{self.name} -> VOTE_YES; state=PREPARED")
return True
def commit(self):
if self.state != "PREPARED":
raise RuntimeError(f"{self.name} cannot commit from state {self.state}")
self.balance += self.pending_delta
self.pending_delta = 0
self.state = "COMMITTED"
self.log.append("COMMIT")
print(f"{self.name} <- COMMIT; state=COMMITTED")
def abort(self):
self.pending_delta = 0
self.state = "ABORTED"
self.log.append("ABORT")
print(f"{self.name} <- ABORT; state=ABORTED")
class CoordinatorCrash(Exception):
pass
class Coordinator:
def __init__(self, transaction_id):
self.transaction_id = transaction_id
self.log = EventLog(f"coordinator_{transaction_id}")
def execute(self, operations, crash_after_decision=False):
participant_names = [participant.name for participant, _, _ in operations]
self.log.append("START", {"participants": participant_names})
print("COORDINATOR -> PREPARE")
votes = []
for participant, delta, accepts in operations:
votes.append(participant.prepare(delta, accepts))
decision = "COMMIT" if all(votes) else "ABORT"
self.log.append(f"DECISION_{decision}")
print(f"COORDINATOR: durable decision={decision}")
if crash_after_decision:
print("COORDINATOR CRASHES before broadcasting the decision")
raise CoordinatorCrash("simulated coordinator failure")
self._broadcast([participant for participant, _, _ in operations], decision)
self.log.append("COMPLETE")
return decision
@staticmethod
def _broadcast(participants, decision):
print(f"COORDINATOR -> {decision}")
for participant in participants:
if decision == "COMMIT":
participant.commit()
else:
participant.abort()
def show_balances(label, participants):
balances = ", ".join(f"{participant.name}={participant.balance}" for participant in participants)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
def run_successful_2pc():
reset_logs()
account_a = Participant("A", 100, "tx-success")
account_b = Participant("B", 50, "tx-success")
Coordinator("tx-success").execute([(account_a, -30, True), (account_b, 30, True)])
show_balances("After", [account_a, account_b])
def run_crash_and_recovery():
print("\n=== 3. COORDINATOR CRASH AND RECOVERY ===")
reset_logs()
account_a = Participant("A", 100, "tx-crash")
account_b = Participant("B", 50, "tx-crash")
coordinator = Coordinator("tx-crash")
participants = [account_a, account_b]
show_balances("Before", participants)
try:
coordinator.execute([(account_a, -30, True), (account_b, 30, True)], crash_after_decision=True)
except CoordinatorCrash:
print("Participants are blocked:", account_a.state, account_b.state)
show_balances("While blocked", participants)
if __name__ == "__main__":
run_naive_failure()
run_successful_2pc()
run_crash_and_recovery()
How to use this reference
Compare the class boundaries and indentation with your file. Pay close attention to the placement of CoordinatorCrash and the final main block.
Before you run it, what state do you expect each participant to report after the coordinator disappears?
- Run the complete simulator by entering:
python two_phase_commit.py
What does the final run reveal?
The coordinator records DECISION_COMMIT before the injected crash. No commit message reaches either participant.
Both participants therefore report PREPARED while their visible balances remain unchanged.
You should see Participants are blocked: PREPARED PREPARED. The next line should show While blocked: A=100, B=50, total=150.
Why are the participants blocked?
Each yes vote promises that the participant can commit its pending update. The participant still needs the coordinator's global decision before making that update visible.
Guessing could split the transaction if another participant chose a different outcome. Waiting preserves atomicity even though it blocks progress.
- In the VS Code file sidebar, expand the logs folder.
- Select coordinator_tx-crash.json.
- Confirm that the coordinator log contains DECISION_COMMIT.
- Select A_tx-crash.json.
- Confirm that account A recorded a VOTE_YES event with a delta of -30.
- Select B_tx-crash.json.
- Confirm that account B recorded a VOTE_YES event with a delta of 30.
You have now captured the dangerous middle state. The global commit decision is durable while both proposed balance changes remain pending.
Did the participants commit before the crash?
Check that the failure condition appears before the call to _broadcast(). Confirm that the scenario passes crash_after_decision=True.
If the coordinator log lacks DECISION_COMMIT, check that self.log.append(f"DECISION_{decision}") remains above the failure condition.
Ask for help with the crash placement.
You have reproduced the blocking point in two-phase commit without losing the authoritative decision. Next up, you will restart the coordinator and use that durable record to finish the transaction atomically.
Recover the Durable Decision
Your participants are safely blocked in PREPARED after the coordinator crash. That waiting state is the central recovery problem in two-phase commit.
Guessing could split the transaction into conflicting outcomes. A restarted coordinator needs to reload the durable JSON decision before either participant changes its balance.
In this step, get ready to:
- Teach the event log to find the latest global decision.
- Restart the coordinator with the same transaction ID.
- Rebroadcast the recovered decision to both blocked participants.
Read the durable decision
The coordinator log can contain several events for one transaction. Recovery needs the latest commit or abort decision because that record controls the final outcome.
- In two_phase_commit.py, locate the end of the append method inside EventLog.
- Add the following method directly below append:
def last_decision(self):
for entry in reversed(self.events):
if entry["event"] == "DECISION_COMMIT":
return "COMMIT"
if entry["event"] == "DECISION_ABORT":
return "ABORT"
return None
What does this code do?
- The reversed(self.events) loop searches from the newest log entry to the oldest.
- A DECISION_COMMIT event returns COMMIT as the authoritative outcome.
- A DECISION_ABORT event returns ABORT as the authoritative outcome.
- A missing decision returns None so recovery can choose a safe fallback.
- Save two_phase_commit.py.
Restart the coordinator safely
A new coordinator uses the same transaction ID to reconnect with the existing decision log. It can then deliver that recovered outcome to the participant objects that remain blocked.
- Inside Coordinator, locate the end of the execute method.
- Add the following recovery method directly below execute:
def recover(self, participants):
decision = self.log.last_decision()
if decision is None:
decision = "ABORT"
self.log.append("DECISION_ABORT")
print(f"RECOVERED DECISION: {decision}")
self._broadcast(participants, decision)
self.log.append("COMPLETE")
return decision
How does recovery stay safe?
- The restarted coordinator asks last_decision() for the outcome already stored on disk.
- A missing outcome becomes ABORT because no durable commit decision exists.
- The existing _broadcast method delivers the recovered outcome to every participant.
- The COMPLETE event records that recovery finished.
- Save two_phase_commit.py.
- Confirm the new methods preserve the blocked crash scenario by running:
python two_phase_commit.py
What should you see at this checkpoint?
The crash scenario still reaches Participants are blocked: PREPARED PREPARED. You should also see While blocked: A=100, B=50, total=150.
This confirms the recovery methods load without changing the original crash boundary.
Seeing a Python error?
Check that last_decision is indented inside EventLog. Check that recover is indented inside Coordinator.
Make sure both methods align with the other methods in their classes.
Help me fix the method placement in my two-phase commit simulator.
The recovery logic now exists, but the crash demonstration still stops after showing the blocked participants. The final change simulates a fresh coordinator and gives it the same transaction ID.
- In two_phase_commit.py, select the entire run_crash_and_recovery function.
- Replace the selected function with this completed version:
def run_crash_and_recovery():
print("\n=== 3. COORDINATOR CRASH AND RECOVERY ===")
reset_logs()
account_a = Participant("A", 100, "tx-crash")
account_b = Participant("B", 50, "tx-crash")
coordinator = Coordinator("tx-crash")
participants = [account_a, account_b]
show_balances("Before", participants)
try:
coordinator.execute(
[
(account_a, -30, True),
(account_b, 30, True),
],
crash_after_decision=True,
)
except CoordinatorCrash:
print(
"Participants are blocked:",
account_a.state,
account_b.state,
)
show_balances("While blocked", participants)
print("Restarting the coordinator with the same transaction ID...")
recovered_coordinator = Coordinator("tx-crash")
recovered_coordinator.recover(participants)
show_balances("After recovery", participants)
What completes the recovery loop?
- The original coordinator still crashes after persisting DECISION_COMMIT.
- The new Coordinator("tx-crash") instance loads the existing coordinator log.
- The recover(participants) call rebroadcasts the durable decision to both blocked participants.
- The final balance check proves the pending debit and credit become visible together.
- Save two_phase_commit.py.
Before you run the completed simulator, do you think the restarted coordinator will commit or abort the blocked transaction?
- Run the completed simulator from the terminal:
python two_phase_commit.py
What should you see after recovery?
The restarted coordinator prints RECOVERED DECISION: COMMIT. It then prints COORDINATOR -> COMMIT before both participants enter COMMITTED.
The final line reads After recovery: A=70, B=80, total=150. The transfer preserves the original total after the coordinator restart.
Recovery does not reach COMMITTED?
Confirm that the restarted coordinator uses tx-crash. A different transaction ID points to a different log file.
Check that recover calls self._broadcast(participants, decision) before recording COMPLETE.
Help me trace why my recovered coordinator does not commit both participants.
You have closed the failure loop. The coordinator now turns a durable decision into an atomic final state after restart.
✔️ Awesome, I've got everything!
Your recovered transaction commits both participants. Double check that you saved two_phase_commit.py.
ⓧ I'd like to double check the full code
Compare your complete two_phase_commit.py file with this version.
import json
from pathlib import Path
LOG_DIR = Path("logs")
def reset_logs():
LOG_DIR.mkdir(exist_ok=True)
for log_file in LOG_DIR.glob("*.json"):
log_file.unlink()
class EventLog:
def __init__(self, name):
LOG_DIR.mkdir(exist_ok=True)
self.path = LOG_DIR / f"{name}.json"
self.events = []
if self.path.exists():
self.events = json.loads(
self.path.read_text(encoding="utf-8")
)
def append(self, event, details=None):
entry = {"event": event}
if details is not None:
entry["details"] = details
self.events.append(entry)
self.path.write_text(
json.dumps(self.events, indent=2),
encoding="utf-8",
)
print(f" LOG {self.path.name}: {event}")
def last_decision(self):
for entry in reversed(self.events):
if entry["event"] == "DECISION_COMMIT":
return "COMMIT"
if entry["event"] == "DECISION_ABORT":
return "ABORT"
return None
class Participant:
def __init__(self, name, balance, transaction_id):
self.name = name
self.balance = balance
self.pending_delta = 0
self.state = "INIT"
self.log = EventLog(f"{name}_{transaction_id}")
def apply_without_2pc(self, delta, accepts=True):
print(f"{self.name}: local update requested ({delta:+})")
if not accepts:
self.state = "REJECTED"
self.log.append("LOCAL_REJECT", {"delta": delta})
print(f"{self.name}: REJECTED")
return False
self.balance += delta
self.state = "COMMITTED"
self.log.append("LOCAL_COMMIT", {"delta": delta})
print(f"{self.name}: COMMITTED immediately")
return True
def prepare(self, delta, accepts=True):
print(f"{self.name} <- PREPARE ({delta:+})")
if not accepts or self.balance + delta < 0:
self.state = "VOTED_NO"
self.pending_delta = 0
self.log.append("VOTE_NO", {"delta": delta})
print(f"{self.name} -> VOTE_NO")
return False
self.pending_delta = delta
self.state = "PREPARED"
self.log.append("VOTE_YES", {"delta": delta})
print(f"{self.name} -> VOTE_YES; state=PREPARED")
return True
def commit(self):
if self.state != "PREPARED":
raise RuntimeError(
f"{self.name} cannot commit from state {self.state}"
)
self.balance += self.pending_delta
self.pending_delta = 0
self.state = "COMMITTED"
self.log.append("COMMIT")
print(f"{self.name} <- COMMIT; state=COMMITTED")
def abort(self):
self.pending_delta = 0
self.state = "ABORTED"
self.log.append("ABORT")
print(f"{self.name} <- ABORT; state=ABORTED")
class CoordinatorCrash(Exception):
pass
class Coordinator:
def __init__(self, transaction_id):
self.transaction_id = transaction_id
self.log = EventLog(f"coordinator_{transaction_id}")
def execute(self, operations, crash_after_decision=False):
participant_names = [
participant.name for participant, _, _ in operations
]
self.log.append("START", {"participants": participant_names})
print("COORDINATOR -> PREPARE")
votes = []
for participant, delta, accepts in operations:
votes.append(participant.prepare(delta, accepts))
decision = "COMMIT" if all(votes) else "ABORT"
self.log.append(f"DECISION_{decision}")
print(f"COORDINATOR: durable decision={decision}")
if crash_after_decision:
print("COORDINATOR CRASHES before broadcasting the decision")
raise CoordinatorCrash("simulated coordinator failure")
self._broadcast(
[participant for participant, _, _ in operations],
decision,
)
self.log.append("COMPLETE")
return decision
def recover(self, participants):
decision = self.log.last_decision()
if decision is None:
decision = "ABORT"
self.log.append("DECISION_ABORT")
print(f"RECOVERED DECISION: {decision}")
self._broadcast(participants, decision)
self.log.append("COMPLETE")
return decision
@staticmethod
def _broadcast(participants, decision):
print(f"COORDINATOR -> {decision}")
for participant in participants:
if decision == "COMMIT":
participant.commit()
else:
participant.abort()
def show_balances(label, participants):
balances = ", ".join(
f"{participant.name}={participant.balance}"
for participant in participants
)
total = sum(participant.balance for participant in participants)
print(f"{label}: {balances}, total={total}")
def run_naive_failure():
print("\n=== 1. NAIVE TRANSFER: ATOMICITY BREAKS ===")
reset_logs()
account_a = Participant("A", 100, "naive")
account_b = Participant("B", 50, "naive")
show_balances("Before", [account_a, account_b])
account_a.apply_without_2pc(-30, accepts=True)
account_b.apply_without_2pc(30, accepts=False)
show_balances("After", [account_a, account_b])
print("Result: A committed alone. The distributed transfer is not atomic.")
def run_successful_2pc():
print("\n=== 2. TWO-PHASE COMMIT: SUCCESS ===")
reset_logs()
account_a = Participant("A", 100, "tx-success")
account_b = Participant("B", 50, "tx-success")
coordinator = Coordinator("tx-success")
show_balances("Before", [account_a, account_b])
coordinator.execute(
[
(account_a, -30, True),
(account_b, 30, True),
]
)
show_balances("After", [account_a, account_b])
def run_crash_and_recovery():
print("\n=== 3. COORDINATOR CRASH AND RECOVERY ===")
reset_logs()
account_a = Participant("A", 100, "tx-crash")
account_b = Participant("B", 50, "tx-crash")
coordinator = Coordinator("tx-crash")
participants = [account_a, account_b]
show_balances("Before", participants)
try:
coordinator.execute(
[
(account_a, -30, True),
(account_b, 30, True),
],
crash_after_decision=True,
)
except CoordinatorCrash:
print(
"Participants are blocked:",
account_a.state,
account_b.state,
)
show_balances("While blocked", participants)
print("Restarting the coordinator with the same transaction ID...")
recovered_coordinator = Coordinator("tx-crash")
recovered_coordinator.recover(participants)
show_balances("After recovery", participants)
if __name__ == "__main__":
run_naive_failure()
run_successful_2pc()
run_crash_and_recovery()
Connect the log to recovery
The terminal shows that recovery works. The coordinator log shows why the restarted process can commit without guessing.
- Return to the file sidebar in Visual Studio Code.
- Expand the logs folder.
- Select coordinator_tx-crash.json.
- Find the object whose event value is DECISION_COMMIT.
You should see the durable DECISION_COMMIT record before the later COMPLETE record. This ordering proves that the global outcome survived before the original coordinator disappeared.
Why does the decision log matter?
A participant in PREPARED has promised that it can commit. It still waits because the final global outcome belongs to the coordinator.
The durable record resolves the in-doubt transaction after restart. Both participants receive the same stored decision before making their pending balances visible.
Secret mission
Force a Global Abort
Force account B to vote NO during prepare. You will prove that one rejected vote makes the coordinator abort every participant without changing either balance.
Clean Up Your Resources
Clean Up Your Resources
Everything you created lives in the local two-phase-commit-lab folder. Keeping it creates no ongoing cost because each Python script stops after it exits.
Resources you used:
- Local two-phase-commit-lab project folder.
- Simulator code in two_phase_commit.py.
- Global abort scenario in secret_mission.py.
- Generated JSON event logs inside the logs directory.
Keep everything running
No action is needed. Choose this option if you want to rerun the simulator or inspect its durable logs.
- The two_phase_commit.py simulator remains available for more failure experiments.
- The secret_mission.py abort scenario remains available for reruns.
- The generated JSON records remain under logs for inspection.
Pause - I'll come back to this later
Pausing closes your editor session while preserving the complete simulator. The scripts already exit without leaving a background process.
- Close Visual Studio Code when you finish.
- Keep the two-phase-commit-lab folder in its current location.
Delete - I don't want to use this again
Deleting the project folder removes both scripts plus every generated JSON log.
- Right-click the two-phase-commit-lab folder in the Explorer sidebar.
- Choose the option that reveals the folder in Windows file management.
- Close Visual Studio Code.
- Select the two-phase-commit-lab folder in the open file manager.
- Delete the selected folder.
- Confirm that two-phase-commit-lab no longer appears in its previous location.
The two_phase_commit.py file is now removed. The secret_mission.py file plus the logs directory are removed with it.
Nice Work!
Nice Work!
Mission complete! Your terminal-based bank transfer simulator makes every two-phase commit vote visible from preparation through recovery.
You've learned how to:
- Build a coordinator state machine plus participant state machines that expose INIT, PREPARED, COMMITTED, plus ABORTED transitions in the terminal.
- Demonstrate a broken distributed atomicity outcome where account A commits its debit while account B rejects its credit.
- Persist votes plus the global decision as readable JSON event logs. Observe blocked participants remain in PREPARED after a coordinator crash. Recover DECISION_COMMIT from the durable log so the transfer finishes atomically.
- Secret Mission: Forced account B to record VOTE_NO. Proved the coordinator records DECISION_ABORT while both balances remain unchanged.
Ready to quiz yourself?