fix(ha-config-bootstrap,ha-repl-hardening): config-less node must not win election

Root cause (found via ha-convergence-recovery task 8 live 3-node E2E): a
controller with no local config (config_version_tag==0) could become PRIMARY
and push an empty config, breaking replication.

Two upstream election-safety gaps, both outside ha-convergence-recovery:

1. RDHUP re-election bypassed the no-config 'voter only' guard. The startup
   path (ha_start_election_async) refuses to start an election when
   my_config_tag==0 && n_peers>1, but the RDHUP failover path
   (ha_on_primary_ctrl_fd_rdhup) called raft_on_leader_lost unconditionally.
   Fix: mirror the guard before elect_ctx alloc / hook / raft_on_leader_lost;
   a config-less node stays CTRL_STANDBY (voter only) and grants the
   configured peer instead of starting (and winning) its own election.

2. Voter freshness gate compared the candidate only against the voter's LOCAL
   config, ignoring the cluster's known committed state. Fix: ha_vote_self_freshness
   floors the comparison at max(local, learned-committed config_tag / committed_lsn),
   so a voter denies any candidate whose config/cluster state is outdated vs
   what the cluster has committed. Strictly additive (never denies the freshest
   node — Leader Completeness preserves liveness).

TDD: test_rdhup_noconfig_node_does_not_elect + configured-node contrast;
test_ha_vote_gate learned-floor deny + liveness + backward-compat. Full
controller suite green under ASAN. commit.c and ha-convergence-recovery
files untouched.
This commit is contained in:
Rongsong Shen 2026-06-14 08:58:08 +00:00
parent 513b965f98
commit c97c7632e9
6 changed files with 439 additions and 9 deletions

View file

@ -657,6 +657,27 @@ int handle_request_vote(ha_state_t *ha,
int in_catch_up_flag);
#endif
/*
* ha_vote_self_freshness: compute the voter's effective freshness floor.
*
* Election safety: a voter must deny candidates that are outdated against the
* strongest knowledge it has of the cluster's COMMITTED state — not merely its
* own local config file / applied LSN. The floor is the per-field max of the
* voter's local state and its learned-committed state:
*
* *out_tag = max(local_config_tag, atomic committed_config_tag)
* *out_lsn = max(local_applied_lsn, atomic last_known_committed_lsn)
*
* Uses acquire loads of the atomic learned-committed fields; never reads the
* non-atomic last_known_committed_config_tag (data-race safe). Pure aside from
* the atomic reads — no state mutation.
*/
void ha_vote_self_freshness(ha_state_t *ha,
uint64_t local_config_tag,
uint64_t local_applied_lsn,
uint64_t *out_tag,
uint64_t *out_lsn);
/*
* ha_elect_evaluate_and_signal: evaluate accumulated ha_elect_ctx_t votes.
* Sets ctx->result (1=primary, 0=standby) and posts ctx->done semaphore.