◐ Off-By-One · answer catalog

go-raft-witness-quorum-log-replication

1 answer(s)gogo1.26

go-raft-witness-quorum-log-replication

📦 Source in repository (JSON)

Answer

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).


Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog