cairn
Design Decisions

Pre-vote and read-index

Why elections use a non-disruptive pre-vote round and reads use read-index instead of a log write — and a real safety bug the design caught before merge.

Decision

RaftCore runs a pre-vote round before any node becomes a real candidate, and serves linearizable reads via read-index rather than routing them through the log. Both are covered in depth in Election and Replication and Read-Index; this page is about why, and about a correctness bug the pre-vote design surfaced in the RPC contract itself.

Why pre-vote

A partitioned node that can't reach the current leader keeps timing out and incrementing its term, over and over, without ever actually winning an election — it can't, because it can't reach a majority. The problem shows up the moment it rejoins: its inflated term outranks the legitimate leader's, and a plain Raft implementation forces that leader to step down for no real reason, disrupting a cluster that was working fine. Pre-vote asks peers "would you vote for me" without incrementing the asking node's term or persisting anything on the responding side — only a majority pre-vote grant lets the node proceed to a real, term-incrementing election. A partitioned node's pre-votes simply never reach quorum, so it never disrupts anything.

Why read-index over a log-routed read

The alternative — append a no-op command, wait for it to commit, then read — is correct but puts every read through a log append and an fsync. For a project whose storage engine's whole point is a fast write path (LSM over B-tree), taxing every read the same way a write is taxed gives up a real advantage for no correctness gain. Read-index confirms leadership via a heartbeat quorum round instead — no log growth per read — while still proving the read reflects everything committed before it began.

The bug pre-vote's own contract caught

Adding pre-vote isn't free: a RequestVote response has to be distinguishable as either "yes, I'd hypothetically vote for you" (a pre-vote grant) or "yes, you have my vote this term" (a real grant) — conflating the two is a latent election-safety violation. The original "frozen" RPC contract was RequestVoteResp { term, vote_granted }, with no field to tell those two cases apart.

The failure mode: if a peer receiving a pre-vote request happens to already be sitting at the candidate's prospective term (T+1) for unrelated reasons, its grant is ambiguous by construction — a candidate counting that response toward a real vote tally, when the peer only meant to grant a pre-vote, could let two different nodes each believe they won an election in the same term. That's a direct violation of election safety, the first of the four invariants the whole test suite exists to prove — and term-overloading (trying to infer pre-vote-vs-real from term arithmetic alone) turns out not to be fixable; the ambiguity is structural, not a matter of picking cleverer numbers.

This was caught by a whole-branch adversarial review pass — not by any single per-task unit test, since each task's own tests were internally consistent with the contract as specified. The fix extends the contract deliberately: RequestVoteResp gained a pre_vote: bool field, echoing the request's own pre_vote flag back. A candidate now counts a response toward its pre-vote tally only when resp.pre_vote is set, and toward its real-vote tally only when it isn't — the ambiguity is gone by construction instead of by convention. The hand-rolled TCP codec (Transport) was updated for the new field alongside the type change.

The broader lesson kept from this: an RPC contract that looks "frozen" because an earlier design phase settled it isn't actually immutable once a later phase's behavior depends on distinguishing cases the original contract couldn't express — extend it deliberately, with the same adversarial scrutiny as any other consensus-critical change, rather than working around the gap in caller logic.

On this page