go-raft-witness-quorum-log-replication
The bug. In Raft, an entry is committed only when it is stored on a majority of voting nodes (paper §5.4.1). A witness (non-voting) node replicates entries and applies them to its state machine, but its matchIndex must never be included when the leader computes the commit quorum. A naive leader that counts the witness's matchIndex sees "2 of 3 nodes match" (leader + witness) and advances commitIndex — even though only 1 of 2 voters holds the entry. If the leader then fails, the remaining voters elect a new leader that never saw the "committed" entry, and the committed entry is lost.
The fix. Compute the commit index from the sorted matchIndex values of voting nodes only (the paper's median-of-matchIndex rule), then apply Raft's current-term rule (§5.4.2). Witnesses keep replicating and applying — they just drop out of the quorum arithmetic.
type Role int
const (
RoleVoter Role = iota
RoleWitness
)
type Node struct {
ID string
Role Role
MatchIndex int // highest log index known replicated on this node
}
type Leader struct {
ID string
CurrentTerm int
Log []Entry // Log[0] is a dummy entry; real entries start at index 1
Nodes []*Node // every member, including the leader itself
CommitIndex int
}
// votingQuorum: majority over VOTING nodes only — witnesses never counted.
func (l *Leader) votingQuorum() int {
voters := 0
for _, n := range l.Nodes {
if n.Role == RoleVoter {
voters++
}
}
return voters/2 + 1
}
// AdvanceCommitIndex is the fix: quorum = voting nodes only.
func (l *Leader) AdvanceCommitIndex() int {
var match []int
for _, n := range l.Nodes {
if n.Role == RoleVoter { // witnesses excluded — they cannot skew commit
match = append(match, n.MatchIndex)
}
}
if len(match) == 0 {
return l.CommitIndex // a witness-only cluster can never commit
}
sort.Ints(match)
// The (quorum)-th largest voting matchIndex is the highest index that a
// quorum of voters is guaranteed to hold.
candidate := match[len(match)-l.votingQuorum()]
// Raft §5.4.2: only commit entries from the current term.
if candidate > l.CommitIndex &&
candidate <= l.LastIndex() &&
l.Log[candidate].Term == l.CurrentTerm {
l.CommitIndex = candidate
}
return l.CommitIndex
}
For contrast, the buggy variant differs in exactly two lines — it iterates over all nodes and uses the total node count for the quorum:
func (l *Leader) AdvanceCommitIndexNaive() int { // BUG: witness counted
var match []int
for _, n := range l.Nodes {
match = append(match, n.MatchIndex) // ← witnesses included
}
sort.Ints(match)
quorum := len(match)/2 + 1 // ← quorum over all nodes, not voters
candidate := match[len(match)-quorum]
// ...same commitTo logic
}
Witness behavior on the follower side is unchanged — witnesses receive AppendEntries, append to their log, and apply committed commands exactly like voters:
// Apply returns the commands a follower (voter OR witness) applies.
// Replication/application are identical; the witness distinction exists only
// in the leader's quorum computation.
func Apply(log []Entry, commitIndex int) []string {
if commitIndex >= len(log) {
commitIndex = len(log) - 1
}
out := make([]string, 0, commitIndex)
for i := 1; i <= commitIndex; i++ {
out = append(out, log[i].Data)
}
return out
}
Supporting details in the implementation: Replicate is monotonic (a follower that stored index N can never un-store it, so matchIndex only moves forward), and VoterCount(i) exposes the safety invariant checker (VoterCount(commitIndex) >= votingQuorum() must always hold).
Verified with a real Go module (`go 1.26`, `go test -race`, `go vet` clean). **8/8 tests pass.**
**Headline regression — the naive bug is reproduced and the fix refuses to commit:**
```
Scenario: leader L appends 3 entries; witness W replicates all 3,
voter F1 is partitioned and holds nothing.
NAIVE logic: commitIndex = 3 (quorum counted over all 3 nodes)
-> entry committed with only 1 of 2 voting nodes holding it: MINORITY COMMIT (unsafe)
FIXED logic: commitIndex = 0 (quorum counted over voters only)
-> nothing commits: only 1 of 2 voters holds the entries. Safe.
After F1 catches up: commitIndex = 3 (voter quorum of 2 reached)
Witness applies committed commands: [set k=1 set k=2 set k=3]
```
**Edge cases tested** (all `--- PASS`):
1. `TestWitnessDoesNotCountTowardQuorum` — 2 voters + 1 witness; witness replicates all, other voter partitioned. Fixed → `commitIndex=0`; naive → `commitIndex=3` with only 1/2 voters holding it (safety violation logged).
2. `TestMinorityOfVotersCannotCommitWithWitnessHelp` — 4 voters + 1 witness; witness + 1 voter (2/4 voters) match. Fixed → `0`; naive → `3` (violation: only 2/4 voters hold it).
3. `TestWitnessLagsDoesNotBlockCommit` — witness behind at index 0; both voters match → commit proceeds (`3`). Witness lag never holds back a genuine voter quorum.
4. `TestVoterMajorityCommitsDespiteWitnessLag` — 4 voters, 3 match, witness lags → commits.
5. `TestCurrentTermCommitRule` — previous-term entries on a voter quorum do **not** commit directly (§5.4.2); they commit only after a current-term entry commits.
6. `TestEmptyVoterSetCannotCommit` — all-witness cluster never commits (`0`).
7. `TestWitnessAppliesEntries` — witness applies `[a b c]` after commit, identical to voters.
8. `TestSafetyPropertyRandomized` — 50,000 randomized steps on a 4-voter + 2-witness cluster (witnesses replicate aggressively, voters have a 25% failure rate); after every step asserts `VoterCount(commitIndex) >= 3`. **Passed with 5 different seeds.** Swapping in the naive logic makes this same test fail at step 24 with a real violation:
```
step 24: SAFETY VIOLATION — commitIndex 9 held by only 2 voters (need 3) ← naive FAILS
```
while the fixed logic holds the invariant for the full run.
**Files:** `~/raftwitness/raft.go` (core logic), `raft_test.go` (8 tests), `cmd/demo/main.go` (scenario demo).
---{"model": "deepseek-v4-flash", "problem_class": "go-raft-witness-quorum-log-replication", "result": "passed", "tests": 8}