8000 fix(cluster): new leader appends a no-op so previous-term entries commit by xerj-org · Pull Request #2987 · xerj-org/xerj · GitHub
[go: up one dir, main page]

Skip to content

fix(cluster): new leader appends a no-op so previous-term entries commit - #2987

Open
xerj-org wants to merge 2 commits into
mainfrom
fix/cluster-leader-noop-commit
Open

xerj-org wants to merge 2 commits into
mainfrom
fix/cluster-leader-noop-commit

Conversation

@xerj-org
@xerj-org xerj-org commented Nov 6, 2026
Copy link
Copy Markdown
Owner

Problem

A newly elected leader never committed entries left by the previous term. RaftNode::become_leader (engine/crates/xerj-cluster/src/raft.rs) only reset next_index/match_index. The commit rule in handle_append_entries_response correctly counts replicas only for entries of the current term (Raft §5.4.2), so a tail from term T-1 stayed at commit=0 until some client proposed something. Metadata writes interrupted by an election hung and timed out. The rc.101 CHANGELOG "Fixed" line said this was fixed, but no such code existed.

Fix

  • ClusterCommand::Noop, added as the last variant. Log entries, snapshots and wire messages are serde_json (externally tagged), so every existing variant encodes exactly as before. Noop encodes as "Noop".
  • become_leader appends a Noop at current_term through append_own, the persist path propose now shares (append, fsync, log end; a single-node cluster commits it at once). Both callers send AppendEntries right after. If the persist fails, the node steps down to follower and sends nothing, the same way start_election handles a failed vote write. The leader counts itself implicitly in the commit rule (1 + match_index ≥ n), so it has no own match_index entry to update.
  • Apply sites ignore it. ClusterMetadata::apply has an empty arm (the applied index still advances). ClusterNode::apply_entry returns before the engine applier and before the waiter lookup, so no client waiter can resolve on a Noop index (waiters are keyed on ClientMetadata request ids anyway). The catch-all in cluster_apply already returns Applied. Snapshots, compaction and take_installed_entries carry it like any other entry.
  • CHANGELOG: an Unreleased "Fixed" entry, plus a note that the rc.101 line was premature. The rc.101 entry is left as it was.

⚠️ Upgrade: mixed-version rings

An older build cannot decode Noop: a frame fails deserialize RaftMessage, and an on-disk entry fails decode raft entry. A rolling upgrade has to upgrade every member before a new-build node can lead, and there is no in-place downgrade after a new build has written a no-op. This is in the CHANGELOG upgrade notes.

Regression test: tests/prev_term_commit.rs

Adapted from the audit repro. n1 leads term 1 and appends entry X. Only n2 stores X, and n2's ack is lost. n1 goes away and n2 wins term 2 with n3's vote. Assertion: entry on a majority is committed by the new leader.

result
origin/main (fix stashed) FAILED: entry 1 on a majority is committed by the new leader (leader commit=0)
this branch ok, 20/20 runs

New unit tests: a freshly elected leader's log ends with a Noop at its own term, and a leader that cannot persist its no-op (blocked hard.meta.tmp) steps down and sends nothing. persistent_raft_test checks that Noop round-trips through the file log.

log_catchup deflake (D2)

  • All proposals go through propose_until, which finds the leader and re-proposes only once the write is provably dead, meaning some member has committed a different entry at its index. The first version re-proposed whenever the new leader lacked the entry. Under 60 ms fsync it committed CreateIndex twice in one run (6 create events instead of 3), because a later leader that still held the entry committed it as well. The no-op is what makes a dead write show up promptly. The task text suggested going through ClusterHandle. These tests drive ClusterNode::step from the test task itself, so awaiting a handle proposal there would block the loop that has to commit it.
  • Members step in parallel, one thread per member, the way separate machines run, instead of one after another. Stepping them in turn added up every member's fsync wait, and a waiting member saw that as a missed heartbeat.
  • No timeouts were changed: pump_until is still 10 s, and propose_until uses the 20 s deadline from the brief.

Numbers (all from runs on this host)

log_catchup test binary under TMPDIR=<ext4 dir> strace -f -qq -o /dev/null -e trace=fsync,fdatasync -e inject=fsync:delay_exit=D -e inject=fdatasync:delay_exit=D $B --test-threads=2, 10 runs per delay. "commit=0" counts timeouts where the leader was stuck at commit=0.

build 20 ms fail 40 ms fail 60 ms fail leader commit=0 timeouts (30 runs)
origin/main 3/10 7/10 10/10 11 (0/4/7)
no-op + dead-only retry, sequential stepping 0/10 0/10 10/10 0
this PR (+ parallel stepping) 0/10 0/10 7/10 0

The 60 ms target (≤1/10) is not met. All of the remaining 60 ms failures are "no leader": every member is a candidate or has no leader when the 20 s deadline hits. None is a commit stall. Each term or vote change costs two syncs (the hard.meta write and a directory sync), and each append costs three. At 60 ms per sync that is longer than the 150–300 ms election timeout, so elections livelock. That is a mismatch between the election timeout and disk latency (pre-vote, or a timeout scaled to measured fsync latency). It is a separate follow-up, and I did not loosen timeouts here.

Other suites:

  • cargo test --release -p xerj-cluster: 127 passed, 0 failed, 5/5 runs. fmt and clippy -D warnings are clean.
  • xerj-api: cluster_metadata_replicates 18/18, cluster_metadata_survives_restart 13/13, cluster_settings_merge 5/5.
  • xerj-engine: cluster_state_compatibility 7/7, lib cluster* 13/13.
  • I did not run ES-YAML. This PR changes nothing in xerj-api or xerj-engine request paths: those crates have zero changes, and the Raft apply path only runs in cluster mode.

Reference-coding

No raft or consensus corpus exists in ~/.xerj-code/corpora (no etcd, raft-rs, tikv or openraft). The reference server on :9200 reports 0 of 21 corpora loaded, and those indexes are more than 30 days old. Nothing relevant was retrieved. The design follows the Raft paper (§5.4.2, §8: "each leader commit a blank no-op entry into the log at the start of its term").

🤖 Generated with Claude Code

Root cause: RaftNode::become_leader (xerj-cluster/src/raft.rs) only reset
next_index/match_index and appended nothing. The commit rule in
handle_append_entries_response correctly counts replicas only for entries
of the current term (Raft §5.4.2), so a tail left by the previous leader,
even when it sat on a majority, stayed uncommitted until a client proposed
something. Metadata writes interrupted by an election hung at commit=0 and
timed out. The rc.101 CHANGELOG "Fixed" line claimed this fix; no such code
existed.

Fix:
- ClusterCommand::Noop, added as the last variant. Entries are serde_json
  (externally tagged), so older variants encode exactly as before; Noop
  encodes as "Noop". An older build cannot decode it (upgrade note added).
- become_leader appends Noop at current_term through append_own, the same
  persist path propose uses (append + fsync + log end; single-node commits
  at once), and returns false after stepping down to follower if that
  write fails. Both callers send AppendEntries immediately on success.
- Apply sites ignore it: ClusterMetadata::apply has an empty arm (the
  applied index still advances); ClusterNode::apply_entry returns before
  the engine applier and the waiter lookup, so no client waiter resolves
  on a Noop index (waiters key on ClientMetadata request ids anyway).
  cluster_apply's catch-all already returns Applied; snapshot/compaction
  and take_installed_entries carry it like any entry.

Tests:
- tests/prev_term_commit.rs (from the audit repro): entry on a majority
  is committed by the new leader. main: FAILED (leader commit=0);
  this branch: ok, 20/20 runs.
- unit: a new leader's log ends with a Noop of its term; a leader that
  cannot persist its no-op steps down and sends nothing.
- persistent_raft_test: Noop round-trips through the file log.
- log_catchup deflake: proposals go through propose_until, which
  re-proposes only when the write is provably dead (a member committed a
  different entry at its index); a timer-based retry committed CreateIndex
  twice in one 60 ms run (6 create events vs 3). Members step in parallel,
  as separate machines do, instead of summing each member's fsync waits.
  No timeout changed (pump 10 s, propose 20 s).

Numbers (log_catchup binary under strace fsync/fdatasync delay injection,
--test-threads=2, 10 runs per delay):
  before (main):          20ms 3/10 fail, 40ms 7/10, 60ms 10/10;
                          leader commit=0 timeouts 0/4/7 = 11 in 30 runs
  after (no-op + retry):  20ms 0/10, 40ms 0/10, 60ms 10/10; commit=0 0
  after (+parallel step): 20ms 0/10, 40ms 0/10, 60ms 7/10;  commit=0 0
The remaining 60 ms failures are all "no leader": every member a
candidate or leaderless at the 20 s deadline. Each vote or term change
costs two syncs (hard.meta write + dir sync) and each append three, which
at 60 ms per sync exceeds the 150-300 ms election timeout. That is an
election-timeout vs disk-latency problem, not this bug; left for a
follow-up.

cargo test --release -p xerj-cluster: 127 passed, 0 failed, 5/5 runs.
xerj-api cluster_metadata_replicates 18/18, cluster_metadata_survives_restart
13/13, cluster_settings_merge 5/5; xerj-engine cluster_state_compatibility
7/7, lib cluster* 13/13.
@cla-bot cla-bot Bot added the cla-signed label Nov 6, 2026
The Unreleased note that corrects the premature rc.101 claim now names
the PR that carries the fix.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0