fix(controller): raft_cb_persist must deny vote and roll back state on persist failure (bug-1684)

ha_persist_term() had a void return -- every open/write/fsync/rename
failure was silently swallowed ("best-effort; caller must not rely on
this for correctness"). Its sole vote-granting caller, raft_cb_persist(),
always returned 0 regardless, so the raft library always granted the
vote even when the term/voted_for record never reached disk -- a real
split-vote risk if the node then restarted and forgot it voted
(Election Safety requires durably-persisted votedFor).

ha_persist_term() now returns 0 on success (including the deliberate
NULL-workdir test-mode skip) or -errno on a genuine I/O failure
(fsync's previously-unchecked return is now checked too). raft_cb_persist()
propagates that failure to the raft library (denying the vote per its
own documented contract) and rolls back ha->voted_for_term/
ha->voted_for_name to their pre-call snapshot, so the adapter's
in-memory state stays consistent with the library's own "deny without
updating voted_for" behavior. ha->term is deliberately NOT rolled back:
the raft library adopts a higher observed term unconditionally at Step 2
of raft_on_vote_request, before and independent of the vote-grant/persist
outcome, so the adapter correctly mirrors that same semantics.

Also corrects a stale claim in controller/CLAUDE.md's Protocol
Verification Checklist (found by a sibling audit dimension the same
session): term+voted_for were never written "atomically under ha->lock"
-- term uses its own atomic store outside the lock.

Found by an /xce-audit-invariants controller pass (error-handling
dimension). 3 new tests in test_ha_durability.c drive the real
production path (ha_init_raft + raft_on_vote_request, the exact
function cluster.c's dispatch calls) against a forced ENOENT write
failure; RED/GREEN independently verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
This commit is contained in:
Rongsong Shen 2026-08-25 07:49:49 +00:00
parent 5441d1b8e6
commit e5e6cd1817
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
4 changed files with 205 additions and 14 deletions

View file

@ -1302,9 +1302,15 @@ int ha_handle_snapshot_chunk(ha_state_t *ha, persist_t *p,
* ha_persist_term: write ha-term.bin atomically to {workdir}/ha-term.bin.
* Uses write + rename for durability (existing pattern in the codebase).
* Must be called before any vote is granted or term is incremented.
* If workdir is NULL, the file write is skipped (safe for unit tests).
* If workdir is NULL, the file write is skipped (safe for unit tests) and
* this returns 0 -- that is a deliberate no-op, not a failure.
* Returns 0 on success (including the NULL-workdir skip); a negative errno
* on a genuine I/O failure (open/write/fsync/rename). Callers on the
* vote-granting path (raft_cb_persist) MUST propagate a non-zero return so
* the raft library denies the vote instead of granting one with no durable
* record (bug-1684).
*/
void ha_persist_term(ha_state_t *ha, const char *workdir);
int ha_persist_term(ha_state_t *ha, const char *workdir);
/*
* ha_init_raft: initialize the embedded raft state machine with cluster topology.