cld-toys › Toys › two-phase-commit

Commentary: two-phase-commit

Kill the coordinator at every step of the protocol and count. The transaction that succeeds blocks the cluster on 10 of 25 crash timings; the one that gets vetoed blocks on 0 of 23. The dangerous path is the happy path. A study guide for tpc.py.

two-phase-commit/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with tpc.py open beside you. tpc.py is the toy itself (180 non-blank lines — two node classes and a driver); demo.py runs the crash sweep, the indistinguishability proof and the boundary; test_tpc.py locks it down with 27 tests. Every transcript below was captured from a real run on macOS 26.5.2 (arm64, Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd two-phase-commit
python3 demo.py                    # the crash sweep and the boundary (§6)
python3 -m unittest test_tpc -v    # 27 tests, pinning every number here
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This is two-phase commit: a coordinator asks every participant to prepare, collects a unanimous vote, and broadcasts commit or abort. The toy runs it as an explicit, numbered message schedule with no threads, no sockets and no clock, which is what makes the central experiment possible — killing the coordinator at every single step of the protocol and counting what the survivors can work out.

filewhat it is
tpc.pythe toy. 180 non-blank lines: Participant, Coordinator, Sim.
demo.pyseven sections of output, all generated, none hand-staged.
test_tpc.py27 tests, mostly invariants rather than transcripts.

No dependencies, no installation, no network.

By the end you should be able to:

The claim this page is built on The backlog originally recorded the aha for this toy as "one participant voting no aborts the whole transaction." Building it killed that claim. It is the protocol's advertised contract — "two-phase commit, unanimous vote" is on the tin, and a reader predicts it before running anything. Worse, measured, it points the wrong way. The NO vote is the safe case. What follows is what replaced it.

2. The problem this mechanism exists to solve

You have one transaction spanning two databases. You need both to commit or neither. The obvious approach — commit the first, then the second — fails at the obvious place: the second one refuses, and the first is already durable.

2PC's move is to split committing into two steps and buy a point of no return. In the first phase every participant is asked to prepare: force undo/redo to disk, take the locks, and guarantee it can commit later. The participant answers YES, and in doing so gives away its right to abort unilaterally. That surrender is the entire protocol. Everything 2PC gets and everything it costs comes from that one exchange.

The competing goals that make more than one design defensible:

You cannot have all three. 2PC picks atomicity and lock-holding, and gives up liveness: it is a blocking protocol. It is never wrong, and it can stop forever. That trade is not a bug or an oversight in the design; this page measures its exact size, and then measures what the famous alternatives actually buy.


3. Background you need

conceptwhere it is used in the toymore
The uncertainty periodParticipant.uncertain() — prepared, no decision heard. This is the state the whole result lives in.BHG ch. 7
Forced log writeself.log.append("prepare") before the vote is sent, in on_prepare. Order matters: reply first and a crash loses the promise.Postgres PREPARE TRANSACTION
Cooperative terminationSim.terminate() — ask live peers instead of guessing. Reduces blocking; provably cannot remove it.BHG §7.4
Indistinguishable statesdemo.py §4. Two runs with identical local state and opposite correct answers — the standard impossibility argument, made concrete.Gray & Lamport
Presumed abort / presumed commitSim.unilateral() — the heuristic resolutions real transaction managers ship.Abadi
Deterministic scheduleSim.step(), a deque of SEND/DELIVER events. No RNG anywhere, so nothing needs seeding.raft-toy uses the same approach

The three flagged ⚑ carry the result. The rest is plumbing.


4. The mental model

COORDINATOR PARTICIPANT =========== =========== INIT INIT | | |----------- prepare --------------->| | | force "prepare" to disk | | take locks | v |<---------- vote YES ---------- PREPARED <-- can no longer | | abort on its own all votes in | | | force "commit" | T H E | | U N C E R T A I N T Y /// crash /// | P E R I O D | | ... waiting ... | ... locks still held ... | ... forever ... The participant may not abort (it promised it could commit). The participant may not commit (nobody told it to). Its peers are in the identical state, so asking them returns its own uncertainty back, once per peer. The deciding bit exists in exactly one place: the dead coordinator's disk.

The window does not open when the coordinator decides. It opens as soon as the last participant has entered PREPARED, which happens well before any vote comes back — and that is the part almost everyone gets wrong. §6 measures where it actually opens.


5. Reading the source

5.1 step — why a crash can land anywhere

tpc.py · lines 105–122
def step(self):
    """Execute exactly one event, and return its one-line description."""
    kind, m = self.q.popleft()
    tag = f"{m['src']}->{m['dst']} {m['kind']:8}{self._payload(m)}"
    if kind == "SEND":
        if self.alive[m["src"]]:
            self.q.append(("DELIVER", m))
            desc = f"SEND    {tag}"
        else:
            desc = f"SEND    {tag} NEVER SENT (sender dead)"
    elif self.alive[m["dst"]]:
        for out in getattr(self.nodes[m["dst"]], "on_" + m["kind"])(m):
            self.q.append(("SEND", out))
        desc = f"DELIVER {tag}"
    else:
        desc = f"DELIVER {tag} DROPPED (receiver dead)"
    self.trace.append(desc.rstrip())
    return desc.rstrip()

The decision to split SEND from DELIVER is the one that makes this toy work at all. A message moves in two events: the sender hands it to the network, and later the receiver's handler runs. If instead a handler atomically emitted all of its outbound messages — the obvious design — then "the coordinator decided, forced commit to its log, and died before transmitting a single copy" would be unreachable, because the decision and all three broadcasts would happen in one indivisible step. That is exactly the state the entire protocol's reputation rests on. The model has to be able to express the bug you are studying.

The asymmetry in the two dead-node branches matters too. A SEND by a dead node never happens; a DELIVER to a dead node is dropped. But a message the coordinator already sent before dying stays in the queue and is delivered — which is why the blocked window closes at step 13 rather than 16.

5.2 on_prepare — where the right to abort is surrendered

tpc.py · lines 36–47
def on_prepare(self, m):
    if self.vote == "no":
        # THE line the one-NO result lives on. A NO voter never enters the
        # uncertainty period at all: it knows the outcome by itself, so it
        # aborts here and stays a live oracle for everybody else.
        self.state = ABORTED
        self.log.append("abort")
        return [dict(src=self.id, dst=C, kind="vote", vote="no")]
    self.state = PREPARED
    self.locks = 1
    self.log.append("prepare")  # forced BEFORE the reply goes out
    return [dict(src=self.id, dst=C, kind="vote", vote="yes")]

Two branches that look symmetrical and are not, in the way that decides the whole page.

The NO voter skips the uncertainty period entirely. It does not need anyone's permission to abort — a single NO makes the outcome abort no matter what anybody else says, so it can write its log record and release its locks immediately. It ends up ABORTED, alive, and certain. That makes it a live oracle: any peer that asks it gets a real answer.

The YES voter does the opposite. It takes locks, forces prepare, and enters a state from which it can neither advance nor retreat without being told. The ordering on that line is load-bearing in the ordinary durability sense — force the record then reply, because a crash between the two must not leave a promise the participant has forgotten making.

The on_prepare NO branch is the single line this toy's headline result turns on. §6 measures what happens when you change it.

5.3 on_vote — one node, one moment

tpc.py · lines 76–85
def on_vote(self, m):
    if self.state != "collecting":
        return []
    self.votes[m["src"]] = m["vote"]
    if len(self.votes) < len(self.pids):
        return []               # unanimity required, so one NO is enough
    d = "commit" if all(v == "yes" for v in self.votes.values()) else "abort"
    self.state = COMMITTED if d == "commit" else ABORTED
    self.log.append(d)          # the decision record, forced to stable store
    return [dict(src=C, dst=p, kind="decision", decision=d) for p in self.pids]

The coordinator waits for every vote, then decides once. Note what this makes true: between self.log.append(d) and the delivery of the first decision message, the outcome of the transaction exists in exactly one place on Earth. Every participant is prepared and knows nothing. That is not an unlucky interleaving — it is a mandatory phase that every 2PC transaction passes through, every time.

5.4 terminate — the best the survivors can do

tpc.py · lines 147–170
def terminate(self):
    """The cooperative termination protocol (Bernstein, Hadzilacos and
    Goodman s7.4): an uncertain participant asks every live peer what it
    knows, and the group resolves if ANY peer is outside the uncertainty
    period.

    Returns "commit", "abort", or "BLOCKED" -- the last meaning every live
    participant is uncertain, so the group's uncertainty set is still
    {commit, abort} and no rule can pick between them.
    """
    live = [p for p in self.parts.values() if self.alive[p.id]]
    known = None
    for p in live:
        if p.state == COMMITTED:
            known = "commit"
        elif p.state == ABORTED and known != "commit":
            known = "abort"
        elif p.state == INIT and known is None:
            known = "abort"     # it never voted, so commit was impossible
    if known is None:
        return "BLOCKED"
    for p in live:
        p.on_decision(dict(decision=known))
    return known

This is the smartest thing the survivors can legally do, and it is worth being clear that it is genuinely smart — it is not a strawman. It rescues every situation where any live participant sits outside the uncertainty period, in either direction:

return "BLOCKED" is the honest branch. When every live participant is uncertain, the group's uncertainty set is still {commit, abort}, and the function does not guess. A protocol that guesses here is a different protocol with different guarantees, which §6 also measures.

5.5 run_crash_at — the experiment harness

tpc.py · lines 191–210
def run_crash_at(votes, k, recovery="terminate", heuristic="abort"):
    """Run the schedule, crash the coordinator immediately after step `k`,
    drain whatever is still in flight, then recover. `k=0` means it died
    before doing anything at all. `recovery="none"` freezes the cluster
    exactly as the crash left it, for inspection."""
    s = Sim(votes)
    for _ in range(k):
        if not s.q:
            break
        s.step()
    s.crash(C)
    s.run()
    if recovery == "terminate":
        return s, s.terminate()
    if recovery == "unilateral":
        s.unilateral(heuristic)
        return s, None
    if recovery == "none":
        return s, None
    raise ValueError(recovery)

Because the schedule is deterministic, k is a complete description of a failure scenario. There is no "run it a thousand times and see" here and no RNG to seed: the sweep in §6 is exhaustive over crash timings, so "40% of crash timings block" is a count, not an estimate.


6. The demo, and what it proves

6.1 A clean run

1 SEND C->1 prepare 2 SEND C->2 prepare 3 SEND C->3 prepare 4 DELIVER C->1 prepare 5 DELIVER C->2 prepare 6 DELIVER C->3 prepare 7 SEND 1->C vote yes 8 SEND 2->C vote yes 9 SEND 3->C vote yes 10 DELIVER 1->C vote yes 11 DELIVER 2->C vote yes 12 DELIVER 3->C vote yes 13 SEND C->1 decision commit 14 SEND C->2 decision commit 15 SEND C->3 decision commit 16 DELIVER C->1 decision commit 17 DELIVER C->2 decision commit 18 DELIVER C->3 decision commit 19 SEND 1->C ack 20 SEND 2->C ack 21 SEND 3->C ack 22 DELIVER 1->C ack 23 DELIVER 2->C ack 24 DELIVER 3->C ack 24 steps. Final states: {1: 'committed', 2: 'committed', 3: 'committed'} locks held: 0

24 steps, so 25 distinct crash timings: k=0 (died before sending anything) through k=24.

6.2 The sweep

Kill the coordinator after each step in turn. The all-YES table, k=0 through k=14 (the remaining rows are all commit):

k resolution P1 P2 P3 locks 0 abort aborted aborted aborted 0 1 abort aborted aborted aborted 0 2 abort aborted aborted aborted 0 3 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 4 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 5 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 6 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 7 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 8 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 9 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 10 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 11 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 12 BLOCKED prepared prepared prepared 3 <== BLOCKED FOREVER 13 commit committed committed committed 0 14 commit committed committed committed 0

And the headline:

all-YES : 10/25 crash timings block = 40.0% one-NO : 0/23 crash timings block = 0.0%

The transaction that succeeds is the one that can hang the cluster. The transaction that gets vetoed cannot, ever.

6.3 The arithmetic

Never leave a headline number underived, so:

3 + 10 + 12 = 25. ✓ 10/25 = 40.0%.

Look at where the window opens: step 3, the moment the last prepare is handed to the network — before a single vote has come back, and nine steps before the coordinator decides anything. The intuition that the danger is a narrow race around the decision broadcast is wrong by most of the protocol. The dangerous window is essentially the entire voting phase.

6.4 Why waiting cannot help

The reflex is that a timeout fixes this. It cannot, and the demo shows why by finding two runs a participant cannot tell apart. Both are searched for, not assumed — and both land at k=12:

WORLD A (k=12): all three vote YES. The coordinator decides COMMIT, forces the record, and dies before transmitting it. WORLD B (k=12): P2 votes NO. The coordinator decides ABORT, forces the record, and dies before transmitting it. P1's complete local state, world A: {'state': 'prepared', 'my_vote': 'yes', 'forced_log': ['prepare'], 'locks': 1} P1's complete local state, world B: {'state': 'prepared', 'my_vote': 'yes', 'forced_log': ['prepare'], 'locks': 1} IDENTICAL? True coordinator's forced log, world A: ['commit'] -> correct: COMMIT coordinator's forced log, world B: ['abort'] -> correct: ABORT

Byte-identical local state; opposite correct answers. A timer carries no information about which world you are in, so it cannot separate them. However long P1 waits, whichever way it eventually jumps, there is a world with exactly its state where that jump is wrong. demo.py asserts the equality so the claim cannot rot.

And the peers add nothing:

And the peers, world A at k=12: P1: state=prepared log=['prepare'] locks=1 P2: state=prepared log=['prepare'] locks=1 P3: state=prepared log=['prepare'] locks=1

All three are in the identical position. Asking every peer returns your own uncertainty back, once per peer. The deciding bit is not in the cluster.

6.5 What the locks are protecting: nothing, nine times out of ten

coordinator HAD forced a decision: k=[12] (1 of 10) coordinator had decided NOTHING: k=[3, 4, 5, 6, 7, 8, 9, 10, 11] (9 of 10)

Three locks are held in all ten blocked timings. But in nine of the ten the coordinator had not decided anything at all — nothing was committed, and abort was legal the entire time. The cluster freezes all ten times regardless, because from the inside those nine are indistinguishable from the one. This is the sharpest thing the sweep shows: the cost is paid on every crash in the window, and the outcome being protected exists in one case out of ten.

6.6 The counterfactual: refuse to block

Rip out the peer query and let each uncertain participant guess when its timer fires.

trace policy blocked split stale split at k all-YES terminate 10/25 0 0 [] all-YES unilateral/abort 0/25 2 1 [13, 14] all-YES unilateral/commit 0/25 2 0 [1, 2] one-NO terminate 0/23 0 0 [] one-NO unilateral/abort 0/23 0 0 [] one-NO unilateral/commit 0/23 14 0 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

("SPLIT" = participants disagree with each other. "STALE" = they agree, but opposite to the decision the coordinator forced and will hand out on restart.)

The split-brain, step by step:

k=11 log=[] P1=aborted P2=aborted P3=aborted ok k=12 log=['commit'] P1=aborted P2=aborted P3=aborted stale (all wrong) k=13 log=['commit'] P1=committed P2=aborted P3=aborted SPLIT BRAIN k=14 log=['commit'] P1=committed P2=committed P3=aborted SPLIT BRAIN k=15 log=['commit'] P1=committed P2=committed P3=committed ok

Two adjacent steps decide whether the database is consistent. At k=13 the coordinator got one commit onto the wire before dying: P1 commits, P2 and P3 time out and roll back. Half the transaction is durable and half is gone, and nothing anywhere reports an error.

So presumed-abort is not a fix. It is a decision to be wrong 3/25 of the time instead of stuck 10/25 of the time. That is the real trade, and it is the one every production transaction manager has to make.

6.7 Which lines are load-bearing

Each variant was applied to the shipped module and re-run against the identical sweep.

#changeall-YESone-NOverdict
as written10/250/23baseline
CF1NO voter enters PREPARED before replying "no"10/2510/25 = 40.0%load-bearing
CF2drop the INIT clause from terminate()13/252/23load-bearing
CF3coordinator never forces its decision record10/250/23inert

CF1 is the one. Move a NO voter into the uncertainty period like everyone else — three lines, no change to any message — and the vetoed transaction goes from never blocking to blocking exactly as often as the successful one. The immunity was never about the abort outcome. It was about there being one participant left alive who never gave up its certainty.

CF3 is the line most people assume is the fix Removing the coordinator's forced decision record changes blocking not at all. Durability of that record governs what a restarted coordinator does; it has no bearing on whether the survivors are stuck, because they were never going to see it. Reliable logging and non-blocking are unrelated properties, and 2PC has the first without the second.

6.8 Roads not taken, measured: does 3PC fix it?

Three-phase commit adds a pre-commit round precisely to destroy the state that blocks 2PC. It works, on the failure this page has been studying:

protocolcoordinator crash, all participants reachable
2PC10/25 timings blocked
3PC0/37 timings blocked

Then partition the participants and run the identical sweep:

protocolcombinationsatomicity violationsblocked
2PC75038
3PC11140

3PC's four violations all occur where one participant reached precommitted and the others are still merely prepared: the minority side sees "everyone uncertain, so abort", the majority side sees a pre-commit and commits.

3PC is not a fix. It moves the failure out of the liveness column and into the safety column — and a silent atomicity violation is a considerably worse outcome than a stuck transaction you can page someone about. This is why 3PC is famous and essentially unused, and why the modern answer is to replicate the coordinator with a consensus protocol instead (see raft-toy). Gray and Lamport put it best: two-phase commit is the trivial version of Paxos Commit that tolerates zero faults.

Provenance of the 3PC rows The 2PC rows above come from the shipped tpc.py. The 3PC rows come from a prototype that is deliberately not shipped in this repo — a second state machine would make this a toy about two mechanisms. They are real executed numbers, but unlike everything else on this page you cannot reproduce them from the files here.

6.9 The boundary: where the effect vanishes

Not with cluster size. The closed form is exact and demo.py asserts it for every N:

N steps timings blocked fraction window 1 8 9 4 44.44% k=1..4 2 16 17 7 41.18% k=2..8 3 24 25 10 40.00% k=3..12 4 32 33 13 39.39% k=4..16 5 40 41 16 39.02% k=5..20 6 48 49 19 38.78% k=6..24 7 56 57 22 38.60% k=7..28 8 64 65 25 38.46% k=8..32

steps = 8N, blocked = 3N+1, so the fraction is (3N+1)/(8N+1), which falls to 3/8 = 37.5% and stays. Adding participants makes it very slightly better and never fixes it. You cannot size your way out.

It is the vote, and it is a cliff. At N=5, varying how many participants veto:

NO voters timings blocked fraction 0 41 16 39.02% 1 39 0 0.00% 2 37 0 0.00% 3 35 0 0.00% 4 33 0 0.00% 5 31 0 0.00%

Zero vetoes blocks 16 of 41 timings. One veto blocks zero. So does two, three, four, five. There is no gradient — one veto is the entire boundary, because one veto is enough to leave a certain participant alive.

So: place your own system by asking not "how many nodes do I have" but "is there any live participant that never entered the uncertainty period?" If yes, you are fine. If no, you are one coordinator crash from a stuck transaction, and no amount of hardware helps.

A caveat about the 40.0% It is a fraction of schedule steps, and step granularity is a modelling choice — splitting SEND from DELIVER is what makes "crashed after deciding, before transmitting" expressible at all, and a different granularity would move the percentage. Treat 40.0% as this model's reading of a real window, not as a law of nature. What does not depend on granularity: the all-YES versus one-NO contrast, run on the identical model, and the exact closed form 3N+1 of 8N+1.

7. Design decisions and roads not taken

Coordinator crashes only. Participant crashes are the other half of the real failure space and they are deliberately absent. Coordinator-only failure already produces the result, and adding participant failures turns one sweep into a combinatorial one that the LOC budget cannot pay for. The cost of this choice is honest to name: with participant failures, cooperative termination gets worse, because the one live certain peer that rescues the one-NO case can itself be the node that died. The 0% floor in the boundary table is the best case, not the general one.

No coordinator restart. A recovering coordinator with an intact log resolves every blocked timing. Leaving it out is the point: it converts "blocked forever" into "blocked for the duration of the outage", which is a real mitigation and not a solution, and the transaction is stuck holding locks either way. Making recovery visible would also have needed a persistence layer the toy does not otherwise want.

No RNG, no clock, no threads. Other designs would have used a random message scheduler and sampled. Determinism buys the thing that actually matters here: k is a complete description of a scenario, so the sweep is exhaustive and the headline is a count rather than an estimate. Nothing needs seeding because nothing is random.

Unanimity checked with all(), not a quorum. 2PC is not a voting protocol in the quorum sense and it is worth not blurring: any single NO decides the outcome. Quorums appear when the coordinator is replicated, which is Paxos/Raft Commit and a different toy.

terminate() returns a string rather than raising. "BLOCKED" is a legitimate protocol outcome, not an error condition, and the sweep needs to count it. Making it an exception would have implied that being stuck is a malfunction, when it is the specified behaviour.


8. What's simplified vs. the real thing


9. Check yourself

1

The demo blocks at k=3, but the coordinator does not decide until step 12. How can the cluster be stuck nine steps before there is a decision to be stuck about?

Answer

Because blocking is not about the decision existing — it is about the participants having surrendered the ability to act without it. By step 3 all three prepare messages are on the network, so all three participants will become PREPARED and none can abort on its own. From that moment the group's uncertainty set is {commit, abort} and stays that way until someone hears the outcome.

The demo makes this concrete: in 9 of the 10 blocked timings the coordinator's log is empty — it never decided. Abort would have been perfectly legal. The participants block anyway because they cannot distinguish "no decision was made" from "a decision was made and I did not hear it."

2

Why does one NO vote give complete immunity, when the NO voter is not the coordinator and holds no special authority?

Answer

Because unanimity means a single NO determines the outcome by itself. The NO voter does not need to consult anyone, so on_prepare aborts it immediately (tpc.py line 41) — it never enters the uncertainty period, keeps no locks, and stays alive and certain. Cooperative termination only needs one such node: any uncertain peer that asks it gets a real answer.

CF1 proves this is the mechanism rather than a coincidence. Make the NO voter enter PREPARED before replying and the one-NO trace goes from 0/23 blocked to 10/25 — identical to the all-YES case. The immunity came from the surviving certainty, not from the outcome being abort.

3

A colleague proposes: "make the coordinator fsync its decision before sending anything, and we're safe." What does the sweep say?

Answer

It says the blocking is unchanged: CF3 removes the forced decision record entirely and all-YES stays at exactly 10/25.

The forced record governs what a restarted coordinator does. It is genuinely necessary — without it a recovered coordinator could contradict a decision participants already acted on — but it does nothing for the survivors while the coordinator is down, because they were never going to see it. Durability and non-blocking are independent properties. 2PC has the first and not the second.

4

Presumed-abort splits the cluster at k=13 and k=14 but not at k=15. Why do the splits stop there?

Answer

The three decision messages are handed to the network at steps 13, 14 and 15. A message already sent survives the sender's death, so crashing after step 13 means exactly one commit is in flight (P1 commits, P2 and P3 guess abort → split); after step 14, two are (P1 and P2 commit, P3 aborts → split); after step 15 all three are in flight and everyone commits, so there is nothing left to disagree about.

Which is why the window is exactly the size of the broadcast. With N participants there are N such steps, so presumed-abort's exposure grows linearly with cluster size while 2PC's blocking fraction stays near 3/8.

5

You run three services, each with its own database, joined by 2PC through a transaction manager on one host. What is your actual availability exposure, and what is the cheapest thing that meaningfully reduces it?

Answer

Every distributed transaction passes through the uncertainty window — it is mandatory, not a rare race. If the transaction manager dies in that window, all three databases hold locks on those rows until it comes back, and conflicting transactions queue behind them. The exposure is not "rare crash times narrow window"; it is "crash times roughly 3/8 of the protocol's duration", and the blast radius is every transaction touching the same rows, not just the one in flight.

The cheapest real mitigation is making the transaction manager's recovery fast and automatic — durable log on shared/replicated storage, supervised restart — because that converts unbounded blocking into blocking bounded by restart time. Note what that concedes: you have not made the protocol non-blocking, you have made the outage short. The actual fix is to stop having a single coordinator, by replicating it with consensus (raft-toy) or by removing the need for a distributed commit at all.


10. Further reading

Next in this repo: raft-toy — replicating the coordinator so that losing it is survivable, which is the real answer to everything above.