feat(ha-election): per-priority failover election stagger + prevote dispatch-gate

Desync FAILOVER re-elections by node priority (ha_election_stagger_ms /
ha_failover_stagger_fn) mirroring the startup stagger, with seed jitter and
async prevote start; allow prevote in voter roles through the dispatch gate.
Adds HA role and vote-gate tests (stagger desync by priority, prevote on dead
primary, stickiness-lapse grants).

Pre-existing uncommitted work, committed to establish a clean baseline before
ha-commit-durability implementation.
This commit is contained in:
Rongsong Shen 2026-06-18 04:16:54 +00:00
parent 223e1b8578
commit 749e194207
5 changed files with 238 additions and 2 deletions

View file

@ -737,6 +737,50 @@ static void test_pre_vote_granted_when_stickiness_lapsed_and_fresh(void)
printf("test_pre_vote_granted_when_stickiness_lapsed_and_fresh: PASS\n");
}
/* ── bug-660: dead primary (fd<0) must GRANT even when last_activity is fresh ── */
static void test_pre_vote_dead_primary_grants_despite_fresh_activity(void)
{
/* The convergence bug: after the primary DIED (primary_ctrl_fd < 0) the
* standby's last_activity stays falsely "fresh" because the failover
* watchdog (ha_arm_heartbeat_timeout_vtask) re-arms it to now on every retry.
* With NO live primary connection the node must GRANT a fresh higher-term
* PreVote so the cluster converges in ONE round instead of denying with
* reason=fresh_heartbeat for minutes (the observed 11-round / 3-min stall).
* Distinguishes from test_pre_vote_granted_when_stickiness_lapsed_and_fresh
* (which has a STALE heartbeat): here the heartbeat looks FRESH but there is
* no primary connection, so the fix's primary_ctrl_fd>=0 guard is what makes
* it grant. */
ha_state_t ha;
ha_init(&ha, &g_gate_cfg, 0);
atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release);
atomic_store_explicit(&ha.term, 3ULL, memory_order_release);
ha.raft.term = 3ULL;
ha.voted_for_term = 0;
ha.primary_ctrl_fd = -1; /* primary connection dropped (RDHUP) */
set_last_activity_age(&ha, 500L); /* last_activity FALSELY fresh (watchdog re-arm) */
prevote_state_snapshot_t before;
snapshot_prevote_state(&ha, &before);
uint64_t reply_term = 0;
int granted = pre_vote_dispatch_decide(&ha,
4ULL, /* prospective term = current+1 */
100ULL, 50ULL, /* candidate meets the floor */
100ULL, 50ULL,
0, &reply_term, NULL, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
"a STANDBY whose primary connection is gone (fd<0) must GRANT the PreVote "
"even if last_activity looks fresh — else convergence stalls (bug-660)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(3ULL, reply_term,
"granted PreVote reply carries the voter's UNCHANGED term (no adoption)");
assert_prevote_state_unchanged(&ha, &before);
ha_fini(&ha);
printf("test_pre_vote_dead_primary_grants_despite_fresh_activity: PASS\n");
}
/* ── Test suite entry point ───────────────────────────────────────────────── */
void test_ha_vote_gate(void)
@ -771,4 +815,7 @@ void test_ha_vote_gate(void)
RUN_TEST(test_pre_vote_stale_freshness_denies);
RUN_TEST(test_pre_vote_stale_term_denies_no_adoption);
RUN_TEST(test_pre_vote_granted_when_stickiness_lapsed_and_fresh);
/* bug-660 — dead primary (fd<0) must GRANT even when last_activity is fresh */
RUN_TEST(test_pre_vote_dead_primary_grants_despite_fresh_activity);
}