An entry sits on three of five servers — a real majority — and the leader is forbidden to call it committed, because it was created two terms ago. Two steps later it exists on no server at all. Figure 8 of the Raft paper, staged deliberately. A study guide for raft.py.
Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd raft-toy
python3 demo.py # the aha (§6)
python3 test_raft.py # pins every number this page claims
This toy is five simulated servers running a stripped-down Raft: leader election, log replication, and the commit rule. There are no threads, no sockets, no clock and no random number generator. Messages accumulate in a Python list, and a driver decides which ones get delivered and which ones are thrown away.
That is the whole trick. A run is a pure function of the schedule, so the schedule can be written down — and this toy is about one specific schedule, the one drawn as Figure 8 of the Raft paper.
You will see the same five-phase schedule executed twice. The first time, the cluster uses the commit rule almost everybody invents when they first think about replication: an entry is committed once a majority of servers store it. The second time it uses the rule Raft actually specifies. Nothing else differs — figure8() takes the rule as an argument and changes not one other line.
The first run reports an entry committed. Two steps later, that entry does not exist on any of the five servers.
By the end you should be able to:
A replicated log has one job: every server applies the same commands in the same order, and once a client is told "done," it stays done. The second half is the hard part, and it is the only part this toy is about.
"Done" has to mean something specific, because the leader is a single machine that can die at any instant. The obvious definition is a quorum: once a majority of servers have the entry on disk, any future majority must overlap this one, so at least one server in any future election has the entry. That overlap argument is correct, and it is why quorums are everywhere.
The trap is that overlap guarantees the entry survives somewhere; it does not guarantee the next leader keeps it. Those are different claims, and the gap between them is where Figure 8 lives. A future leader is chosen by an election, and the election rule compares logs by their last entry's term, not by whether they contain some particular older entry. A server can hold your majority-replicated entry and still vote for a candidate that does not have it, because that candidate's log looks newer.
So there are two competing goals:
Raft resolves this by making the cheap test legal only for entries the current leader created itself. Everything older gets committed indirectly, carried along behind a current-term entry. That is more conservative than strictly necessary — the paper says so outright, noting there are situations where a leader could safely conclude an older entry is committed (for example if it is stored on every server) — and Raft declines the extra cases for simplicity.
Other designs make other choices. Multi-Paxos attaches a ballot number to each slot independently, so it does not inherit this problem in the same shape; Viewstamped Replication runs an explicit view-change protocol that reconciles logs before the new primary accepts anything. Raft's answer is cheaper to implement and, as this toy shows, has exactly one subtle consequence you have to know about.
| Concept | Where it is used in the toy | One link |
|---|---|---|
| Term — a monotonically increasing integer, at most one leader per term. Raft's only clock. | Node.term, step_down (raft.py line 58). Every message carries one. | Raft paper §5.1 |
| Log entry carries its creating leader's term ⭐ | Entry (raft.py line 16). Without this field the commit rule cannot be written at all. | Raft paper §5.3 |
| Quorum / majority overlap | advance_commit (raft.py line 103): stored * 2 <= len(self.peers) + 1. | Quorum (distributed computing) |
| Election restriction (§5.4.1) — vote only for a log at least as up to date as yours, compared by (last term, last index) | on_vote (raft.py line 122). This is what lets S5 win in step (d). | Raft paper §5.4.1 |
| Commit rule (§5.4.2) ⭐ — never commit an entry from a previous term by counting replicas | advance_commit (raft.py line 105). The whole toy. | Raft paper §5.4.2 |
| Log Matching Property — same index + same term ⇒ identical logs up to that point | send_append (raft.py line 90) sends prev_index/prev_term; on_append checks it. It is why "prior entries are committed indirectly" is sound. | Raft paper §5.3 |
| Persistent vs. volatile state | Node.__init__ (raft.py lines 37–48) and Cluster.crash (raft.py line 200). commit_index is volatile, which has consequences (§6.4). | Raft paper Figure 2 |
The two starred rows carry the result. Everything else is scaffolding you need in order to stage the situation where they matter.
Five servers, one log each, growing left to right. Each cell is command@term. The schedule walks the cluster through five states:
The shape to remember: index 2 is contested. Two different leaders, in two different terms, each put a different command in that slot. A majority holding one of them is not the same as that one having won.
The fix, in one picture:
In reading order. Every excerpt is captioned with its provenance so it cannot drift from the file.
Entry — why the term is stored per recordclass Entry:
"""A log record. Storing the creating leader's `term` is what makes the
commit rule expressible at all."""
__slots__ = ("term", "cmd")
This looks like bookkeeping and is not. An entry's term is set once, by the leader that created it, and it is never rewritten — not when a later leader replicates it, not when it is finally committed. That immutability is the whole reason advance_commit can ask "is this entry mine?" three terms later.
If entries were stamped with the term of whoever last replicated them, b@2 would become b@4 the moment S1 pushed it out in step (c), the commit rule would see a match, and Raft would have exactly the bug it is designed to avoid. The paper flags this explicitly: log entries retain their original term numbers, and the extra complexity in the commitment rules is the price.
on_vote — the election restrictiondef on_vote(self, m):
"""The election restriction: never vote for a candidate whose log is
behind yours, by (term, index) -- a longer log with an older final term
loses to a shorter one with a newer term."""
idx, lt = self.last()
up_to_date = (m["last_term"], m["last_index"]) >= (lt, idx)
granted = (m["term"] == self.term
and self.voted_for in (None, m["src"])
and up_to_date)
Three separate conditions, and the tuple comparison is doing more than it looks. Python compares (last_term, last_index) lexicographically, so term dominates index: a candidate with a shorter log wins the comparison if its final entry is from a newer term.
That is not a quirk of the encoding, it is the rule from §5.4.1, and it is precisely what lets S5 — whose log is [a@1, c@3], two entries — beat S2 and S3, whose logs are [a@1, b@2], also two entries. Term 3 beats term 2. S5 wins and truncates.
The >= rather than > is load-bearing in the most literal way. Change it and the cluster cannot hold a single election:
With >, a candidate must be strictly newer than every voter — but at the start of time all logs are empty and equal, so nobody ever grants a vote, S1 burns all four attempts, and the demo dies with a KeyError on the first client write. With >=, the same call returns term 1 and the role leader.
send_append — the consistency checkdef send_append(self, p):
"""Carries the (index, term) of the entry *before* the ones it sends;
a follower matching there matches everywhere earlier."""
ni = self.next_index[p]
self.send(p, type="append", term=self.term, prev_index=ni - 1,
prev_term=self.log[ni - 2].term if ni > 1 else 0,
entries=self.log[ni - 1:], commit=self.commit_index)
Every AppendEntries carries the index and term of the entry before the ones it is sending. The follower accepts only if it has an entry at that index with that term. That single check is the Log Matching Property: if two logs agree on (index, term) at one position, they are identical at every earlier position.
This is what makes "prior entries are committed indirectly" a theorem rather than a hope. When step (c′) commits index 3, it is not separately verifying that indexes 1 and 2 are replicated — it is relying on the fact that a follower which accepted index 3 must match everywhere below it.
advance_commit — the whole toydef advance_commit(self):
"""THE commit rule. Walk down for the highest index a majority stores;
under RAFT, skip any index whose entry came from an earlier term."""
for n in range(len(self.log), self.commit_index, -1):
stored = 1 + sum(1 for p in self.peers if self.match_index[p] >= n)
if stored * 2 <= len(self.peers) + 1:
continue
if self.rule == RAFT and self.log[n - 1].term != self.term:
continue # <-- Figure 8 lives on this line
self.commit_index = n # everything below n rides along
return
Four judgement calls in nine lines. Three of them turn out to matter, and one does not — all four were checked by writing the variant and running it against the same schedule (§7.4).
The term clause is the mechanism. Delete it — which is exactly what rule == NAIVE does — and the demo's headline flips from "refused" to "committed, then erased."
The return is not an optimisation, despite looking like one. The loop counts down from the newest entry, so the first index that qualifies is the highest one, and returning there is correct. Remove the return and the loop keeps going to lower indexes, each assignment overwriting the last, so commit_index ends up at the lowest qualifying index instead of the highest. Run under the naive rule, that variant reports claimed b@2 committed: False against a baseline of True — it silently un-does the very thing the demo is built to show.
stored counts the leader itself via the leading 1 +. The leader has the entry by construction (it appended it), and match_index only tracks peers. Forget the 1 + and a 5-node cluster needs 3 followers, i.e. 4 of 5, which is a quorum of a 7-node cluster.
The <= is the one that does not matter here, and it is worth knowing why. stored * 2 <= len(self.peers) + 1 rejects; with 5 nodes len(peers) + 1 is 5, so it commits at stored in {3, 4, 5}. Changing it to < gives {3, 4, 5} as well — identical. The two differ only at even cluster sizes: with 4 nodes, <= commits at {3, 4} and < commits at {2, 3, 4}, and 2 of 4 is not a majority. The line is a real off-by-one hazard that this schedule cannot expose, which is a good argument for odd cluster sizes and a better argument for not trusting a line because one test passed.
on_append — the line that erases a commitdef on_append(self, m):
ok = False
if m["term"] >= self.term:
self.role = "follower"
pi, pt = m["prev_index"], m["prev_term"]
ok = pi <= len(self.log) and (pi == 0 or self.log[pi - 1].term == pt)
if ok:
# The leader's log wins, unconditionally. This truncation is the
# line that erases a "committed" entry in the demo.
self.log = self.log[:pi] + list(m["entries"])
self.log[:pi] + list(m["entries"]) is an assignment, not an append. A follower does not merge, negotiate or object; whatever the leader sends replaces everything from pi onward. In step (d) that one expression deletes b@2 from S2, S3 and S4, and in step (e) from S1.
commit_index. The follower has no idea whether the entry it is about to discard was ever declared committed, and it could not act on that knowledge if it had it. There is no defence downstream — which is why all the care has to live on the leader's side, in advance_commit.
Cluster.deliver — determinism, and the partition modeldef deliver(self, only=None, rounds=12):
"""Run until quiet. Anything addressed outside `only` is LOST, not
queued -- the partition model, and what lets a schedule say "this
entry reaches exactly S2"."""
for _ in range(rounds):
batch, self.net[:] = list(self.net), []
if not batch:
break
for m in batch:
if self.nodes[m["dst"]].alive and (only is None or m["dst"] in only):
self.nodes[m["dst"]].recv(m)
Two decisions here.
Messages outside only are dropped, not queued. They are gone. That is what lets the schedule say "this entry reaches exactly S2 and nobody else" in one argument, and it is a faithful model of a network partition that heals after the interesting moment has passed. A queue would be a delay model, which is a different thing and cannot produce step (a).
The loop runs until the network goes quiet. One deliver() call is therefore "let the cluster settle," not "advance one tick" — which is what makes next_index back-off (a reject, a decrement, a resend, an accept) complete inside a single call. rounds=12 is a safety valve against an infinite exchange, not a tuning knob; the demo never approaches it.
Nowhere in this file is there a time, a random, or a thread. The determinism is structural, not injected: there is nothing to seed.
python3 demo.py. The real output follows, in three parts.
This schedule is hand-written. It is an adversary, constructed line by line to break the naive rule — not a run that happened to go this way, and not a seed searched for until something broke. The crashes, the restarts and the three partitions are literal arguments in demo.py, and you should read them as the attack they are. §7.1 explains why a written schedule was chosen over a searched one, and reports what a search actually finds.
Two more things the schedule does on purpose, so they don't read as sleight of hand. Nodes campaign on command — there are no election timeouts, so Cluster.elect is called explicitly at each phase. And elect retries after a stale-term loss: when S1 comes back in step (c) its term is 2 while S3 and S4 are at 3, so its first campaign cannot win, it learns term 3 from the rejection, and stands again to reach term 4. That is real protocol behaviour — a stale candidate losing and re-standing — not a fudge to reach a needed number.
The headline number is 3. Five servers, so a majority is ⌊5/2⌋ + 1 = 3. After step (c), holders(2, 'b') returns [1, 2, 3]:
| server | log after (c) | holds b@2? |
|---|---|---|
| S1 | a@1 b@2 | yes (it is the leader; it created it) |
| S2 | a@1 b@2 | yes (from step (a)'s partial delivery) |
| S3 | a@1 b@2 | yes (from step (c)'s replication) |
| S4 | a@1 | no — still partitioned out |
| S5 | a@1 c@3 | no — has a different entry there |
The leader computes the same count arithmetically: stored = 1 + |{p : match_index[p] >= 2}| = 1 + |{2, 3}| = 3, and 3 * 2 = 6, which is greater than len(peers) + 1 = 5. A genuine majority, by the same test that would commit any current-term entry.
The naive rule stops there and sets commit_index = 2. The transcript line commit_index = 2 [rule = naive] is that assignment.
Step (d) is the punchline, and it turns on one comparison. Candidate S5 sends last_index=2, last_term=3. Each voter evaluates (m["last_term"], m["last_index"]) >= (lt, idx):
| voter | its last entry | its (lt, idx) | (3, 2) >= (lt, idx)? |
|---|---|---|---|
| S2 | b@2 | (2, 2) | (3,2) >= (2,2) → yes |
| S3 | b@2 | (2, 2) | (3,2) >= (2,2) → yes |
| S4 | a@1 | (1, 1) | (3,2) >= (1,1) → yes |
Three grants plus S5's own vote is 4 of 5 — S5 becomes leader of term 5, and its first AppendEntries truncates index 2 on all three. Note what S2 and S3 just did: they voted away an entry they were personally storing. They had b@2 in their logs. Nothing in the vote path looks at that. It compares last terms, and 3 > 2.
The last line of the run is the verdict:
A client that wrote b was told the write was durable. Five servers later hold no trace of it.
Step (d) still happens. S5 still wins, still truncates, and b@2 still ends up on nobody. Raft does not prevent the overwrite — it prevents the lie. That distinction is the single most useful thing on this page. The safety property is not "committed entries are never lost"; it is "an entry that was declared committed is never lost," and the rule earns that by declaring fewer things.
The commit_index = 0 is worth a second look, because it is stronger than "it refused index 2." S1 in term 4 cannot commit index 1 either: a@1 is from term 1, which is also not term 4. Its log is two entries long, a majority stores both, and it may commit neither.
That is not a bug in the toy, it is Raft, and it is why commit_index returning to 0 on restart (Cluster.crash, raft.py line 200) matters — it is volatile state in Figure 2 of the paper. A freshly elected leader genuinely does not know which entries are committed, and cannot find out by counting. The real fix is in §8 of the paper: "Raft handles this by having each leader commit a blank no-op entry into the log at the start of its term." One write of the leader's own term, and everything below it commits at once. test_raft.py pins exactly that: a term-4 leader with [a@1, b@2] and a majority commits 0, and the same leader with [a@1, b@2, no-op@4] commits 3.
The paper's Figure 8(e). Same schedule as before, with one extra line: while S1 is leader in term 4, it accepts one more client command, d, and gets it to the same majority that already has b@2.
commit_index jumps from 0 to 3 in one step. Index 3 qualifies on its own merits — d@4, leader's term 4, stored on 3 of 5 — and indexes 1 and 2 are committed indirectly, by the Log Matching Property: any server that accepted index 3 necessarily matches at 1 and 2.
And now S5 cannot come back. Its last entry is c@3; S2's and S3's is d@4, and (3, 2) >= (4, 3) is false, so they refuse. S5 campaigns four times, burning terms 4, 5, 6 and 7, and collects only S4's vote each time — S4: voted_for=5, while S2 and S3 show voted_for=None at term 7, having stepped down to the higher term without granting anything. Two votes of five is not a majority. b@2 survives on [1, 2, 3].
The effect vanishes the moment the leader writes anything of its own. In a real cluster under load that is microseconds, which is why this hazard is invisible in practice and why it took a figure in a paper to make people see it. The dangerous window is precisely an idle cluster that has just changed leaders — which is also the window right after a failure, when a monitoring system is most likely to be issuing reads.
Two events in the schedule were removed one at a time and the run repeated. Both results were surprising enough to be worth stating.
Remove the partition in step (a) — let b@2 reach all five servers instead of only S2:
S5 wins step (d) exactly as before — but it does no damage, because its log is now [a@1, b@2, c@3]: c landed at index 3, not index 2, so there is no conflict to truncate. The hazard was never about S5 winning. It was about the dropped message in step (a), which is what created two different entries competing for one index.
Remove S5's own write in step (b) — S5 becomes leader but never accepts c:
S5 cannot win step (d) at all. Its last entry is a@1, term 1, against S2 and S3's term 2 — it burns four terms and fails. So the entry that destroys a majority-replicated value is c@3: an entry that was never replicated to a single other server, and that no client was ever told anything about. It never needed to be durable. It only needed to exist, once, on one machine, long enough to raise that machine's last log term.
python3 test_raft.py — 13 tests, all passing, no pytest:
They pin the headline (both rules on the identical schedule, opposite verdicts), the majority arithmetic at step (c), the boundary, the negative result from §7.2, and unit checks on the vote comparison, the next_index back-off, truncation, crash persistence, message loss, and byte-identical output across repeated runs.
The alternative was to give the driver randomised election timeouts and random crash injection, search seeds until one produced a violation, and ship the seed. That is a legitimate technique, and it was measured rather than dismissed.
A search was actually run against this exact raft.py: 3000 seeds, each a 40-round random schedule (random crashes, restarts, campaigns, client writes, and a random delivery subset each round), all under the naive rule, watching for an entry that a leader had reported committed and that later differed at that index. It works — 170 of 3000 seeds, 5.7%, produce a violation. Those figures come from that run and nothing else; they are not quoted from anywhere.
The search was still rejected, on the evidence it produced about itself:
| scripted schedule | searched seed (median of 170 hits) | |
|---|---|---|
| events before the violation | 14 (5 phases) | 52.5 |
| rounds | 5 | 26 (min 6, max 40) |
| highest term reached | 5 | 8 (max 18) |
| typical violation | b@2 at index 2, twice named in the log | index 1 rewritten from x2 to x1 |
A reader can hold the scripted version in their head. The searched version requires trusting a magic number and reading a 50-event trace to find the needle — and worse, it hides the adversary. The whole lesson is what an attacker has to arrange: a dropped message, two crashes, and one write that goes nowhere. A seed says "this happens sometimes." The script says why.
The 5.7% earns its place here anyway, because it answers the obvious objection: if the schedule is hand-picked, is it a contrived special case? No. Random schedules hit it roughly one time in eighteen.
The obvious guess, having read §5.2, is that the (term, index) comparison in on_vote is doing the safety work — that it stops the wrong candidate winning. It was tested by weakening it to compare log length only, ignoring terms entirely, and re-running the identical schedule under the real commit rule.
Nothing changed. claimed=False, b@2 survivors: none, S5's final log [a@1, c@3]. S5 still wins step (d), because with length-only comparison its two-entry log ties S2's and S3's two-entry logs and >= grants the vote anyway.
So the election restriction is not what saves Figure 8 in this schedule; the commit rule is, on its own. This is worth knowing because the two rules are usually taught together as a pair, which makes it easy to assume either one would do. The paper's own safety argument (Figure 9) needs both — the restriction is what makes the general proof go through, and there are other schedules where it is the binding constraint. But it is not load-bearing here, and a commentary that claimed it was would be wrong.
Node.rule is a string compared inside the hot path, which is not how anyone would write production code. It buys the only thing this page really needs: the guarantee that both runs execute the same schedule. Two separate implementations, or a git branch, would leave the reader wondering whether some other difference crept in. One function, one argument, one clause.
Every judgement call in advance_commit and on_vote was replaced with its plausible alternative and re-run against the same schedule:
| change | result |
|---|---|
drop the term clause (rule = NAIVE) | headline flips: commits index 2, then loses it |
>= → > in up_to_date | no election ever completes; first client write raises KeyError |
remove the return in advance_commit | naive's claim flips True → False; commit lands on the lowest qualifying index |
<= → < in the majority test | no change at 5 nodes (both commit at 3+); differs only at even sizes |
| length-only vote comparison | no change (§7.2) |
| deliver step (a) to all five | S5 still wins (d), but c lands at index 3 — no conflict, b@2 survives |
S5 never accepts c in (b) | S5 cannot win (d) at all; b@2 survives on [1,2,3] |
Two of the seven are negative results, and they stayed in. A line that looks decisive and is not is worth as much page space as one that is.
Node.term, voted_for and log are marked persistent by a comment and by crash() not clearing them. Actually writing and fsync-ing them is wal-kv's subject, not this one.Cluster.elect(i) instead, called explicitly. That is not laziness, it is the subject: timeouts are the availability mechanism; they are not the safety mechanism. Every claim on this page is a safety claim — an entry declared committed and then lost — and safety in Raft does not depend on timing at all. The paper is explicit that Raft's correctness never relies on bounded clock skew (it mentions leases as an alternative for read-only queries precisely because they would introduce such a dependency). Adding randomised timeouts would make the schedule unreproducible and would not change a single outcome above. The one thing it would buy — watching a leader election happen on its own — is the backlog's original, weaker idea for this toy, and it teaches nothing you could not learn from an animation.
Messages are Python dicts in a list. No serialisation, no network, no retransmission, no reordering beyond what the driver chooses, and no duplicate delivery. Real implementations must handle all four; the RPCs are specified as idempotent for exactly this reason.
Delivery is synchronous and batched. deliver() runs the cluster to quiescence, so a leader's next_index back-off — reject, decrement, resend, accept — completes atomically from the schedule's point of view. Real leaders do this over many round trips, and real implementations optimise it by having the follower return a conflict hint so the leader can skip a whole term's worth of entries instead of decrementing one at a time.
Everything the follower is missing, in one message, and no heartbeats. The toy sends self.log[ni - 1:] and only ever sends when something changes. Real leaders heartbeat on an interval to suppress elections and to propagate commitIndex, which is why followers here lag on commit in the transcript (S2 shows commit=1 while the leader shows 3).
No state machine. Entries are strings that are never applied to anything. commit_index is the promise; a real system also has lastApplied and a state machine behind it, and the whole point of committing is to feed that machine.
No persistence, no fsync. Marked, not implemented — see §7.5.
Scale. The paper reports its own RAMCloud implementation at roughly 2000 lines of C++ excluding tests, comments and blanks, for a mechanism this toy covers in 152 lines of Python. The missing 1850 lines are almost entirely the five items above.
Budget note. raft.py is 216 lines total against this repo's 100–200 line target. 152 of those are code and the rest are comments and docstrings; the overrun is real and it is because this toy needs three sub-protocols — election, replication, and the commit rule — where every other toy in the repo needs one.
Every answer is derivable from raft.py and the transcripts above, and every one was verified by running it.
At step (c), S1 is leader in term 4 with log [a@1, b@2], and b@2 is stored on 3 of 5 servers. Under the real rule, what is commit_index, and why is the intuitive answer wrong?
0, not 1. The intuitive answer is 1 — surely the term-1 entry that everybody has is committed? But advance_commit tests every candidate index against the leader's current term. a@1 is from term 1 and b@2 is from term 2; S1's term is 4. Neither matches, both are skipped, and commit_index stays at the value it was reset to when S1 crashed, which is 0 (it is volatile state, per Figure 2 — see Cluster.crash, raft.py line 200).
This is exactly why real leaders append a no-op entry of their own term immediately on election: without it, a new leader cannot commit anything, and therefore cannot safely answer a read, until a client happens to write.
In step (d), S2 and S3 both have b@2 in their logs and both vote for S5, which does not. Which line lets them, and what does it compare?
raft.py line 122 — up_to_date = (m["last_term"], m["last_index"]) >= (lt, idx). It compares only the last entry of each log, term first. S5's last entry is c@3 giving (3, 2); S2's and S3's is b@2 giving (2, 2). (3,2) >= (2,2) is true, so the vote is granted.
Nothing in the vote path inspects whether the voter holds some particular older entry, or whether that entry was ever called committed. A voter can and does vote away an entry it is personally storing.
Step (b) has S5 accept a client command c that it replicates to nobody — zero other servers ever see it. Delete that one line from the schedule. Does b@2 still get erased?
No. Without c, S5's log stays [a@1], last entry term 1. In step (d) it needs votes from S2 or S3, whose last entry is b@2, term 2 — and (1, 1) >= (2, 2) is false, so they refuse. S5 collects only S4 and fails, burning terms 4 through 7. b@2 survives on [1, 2, 3].
The lesson: the entry that destroys a majority-replicated value was never replicated to anyone and was never acknowledged to any client. Its entire contribution was raising S5's last log term from 1 to 3.
advance_commit counts down from the newest index and returns at the first qualifying one. Delete the return. What breaks, and why is it not caught by the real rule's run?
The loop continues to lower indexes, and each qualifying one overwrites commit_index, so it ends up at the lowest qualifying index rather than the highest — commit_index can go backwards.
It is invisible in the real rule's run because under RAFT nothing qualifies at all in this schedule, so there is no second assignment to do the damage. Under NAIVE it flips the demo's headline: claimed b@2 committed goes from True to False. A test that only exercised the safe rule would have shipped the bug.
The majority test is if stored * 2 <= len(self.peers) + 1: continue. Change <= to <. Which cluster sizes notice?
Even ones only. With 5 nodes, len(peers) + 1 = 5: <= commits at stored ∈ {3, 4, 5} and < commits at {3, 4, 5} — identical, so this schedule cannot expose the difference. With 4 nodes, <= commits at {3, 4} while < commits at {2, 3, 4}, and 2 of 4 is not a majority: two disjoint "majorities" of 2 could each commit a different entry.
This is the practical argument for odd cluster sizes, and a reminder that a passing test suite on 5 nodes says nothing about the line.
Reaching past the toy. You run a 5-node etcd cluster. The leader dies, a new one is elected, and your monitoring immediately issues a read. The new leader's log contains every committed entry — the Leader Completeness Property guarantees it. Can it answer the read correctly?
Not yet, and this toy shows why. It has every committed entry but does not know which ones they are — its commit_index is volatile and starts at 0, and it cannot recover the answer by counting replicas, because every entry in its log is from an earlier term (question 1).
Two precautions are needed, both from §8 of the paper: commit a blank no-op entry of the new term, which commits everything below it indirectly and establishes commit_index; and exchange heartbeats with a majority before replying, to confirm it has not already been deposed. A read served between election and no-op is the real-world face of the gap this toy stages.
The demo's figure8() is called twice with different rules and produces byte-identical output except where the rule matters. What in raft.py makes that guarantee, and what would break it?
There is no clock, no RNG and no concurrency anywhere in the file — the schedule is the only input, so determinism is structural rather than seeded. deliver() drains a list in insertion order, and elect() is called explicitly rather than fired by a timeout.
Adding randomised election timeouts would break it, which is the trade-off §8 describes. So would iterating a set where order matters — note that deliver iterates batch, a list, and only is only ever membership-tested. test_raft.py's last test runs the whole demo twice and compares the strings.
advance_commit's production cousin is two functions: raft.maybeCommit (raft.go) calls raftLog.maybeCommit(entryID{term: r.Term, index: r.trk.Committed()}), and that in turn commits only if at.term != 0 && at.index > l.committed && l.matchTerm(at) — where matchTerm is t == id.term, the term of the entry at that index against the leader's current term. The same clause, in Go. The no-op is there too: becomeLeader appends &pb.Entry{Data: nil} and panics if it is dropped.