feat(ha-prevote): controller voter-side PreVote dispatch (task 3.1)
Add OPCode_preVoteRequest case in cluster.c, mirroring the real-vote case: freshness floor + reused eligibility/stickiness gate (raft_on_pre_vote_request), reply over preVoteReply with the voter's UNCHANGED term (no adoption), denial logging with candidate id + reason. Pure insertion; real-vote case untouched. 6 new vote-gate tests assert deny-while-leader-alive, grant-when-lapsed, and the no-mutation/term-unchanged contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fac681510
commit
6f95e4cdfc
3 changed files with 444 additions and 2 deletions
|
|
@ -23,7 +23,8 @@
|
|||
#include <unity/unity.h>
|
||||
|
||||
#include "controller.capnp.h"
|
||||
#include "ha.h"
|
||||
#include "ha.h" /* pulls in raft/raft.h: raft_state_t, RAFT_GRANT,
|
||||
* raft_on_pre_vote_request, RAFT_PRE_VOTE_* */
|
||||
|
||||
/* Functions under test — declared here to match their signatures in ha.c */
|
||||
int handle_request_vote(ha_state_t *ha,
|
||||
|
|
@ -431,6 +432,311 @@ static void test_no_learned_state_falls_back_to_local_gate(void)
|
|||
printf("test_no_learned_state_falls_back_to_local_gate: PASS\n");
|
||||
}
|
||||
|
||||
/* ══ Task 3.1: voter-side PreVote dispatch gate (cluster.c OPCode_preVoteRequest) ══
|
||||
*
|
||||
* The PreVote dispatch case in cluster.c is a thin composition of three
|
||||
* primitives that all live in the unit-test binary:
|
||||
* 1. ha_vote_self_freshness() — the freshness floor (Req 2.1, 2.2)
|
||||
* 2. candidate-name → peer index mapping
|
||||
* 3. raft_on_pre_vote_request() — stale-term + the SHARED eligibility gate
|
||||
* (raft_cb_vote_eligible: PRIMARY-deny / fresh-heartbeat-deny / catch-up-deny;
|
||||
* Req 3.1, 3.2, 3.3)
|
||||
* cluster.c is not linked into the unit-test binary (transport deps), so we
|
||||
* exercise the EXACT decision the dispatch composes via this mirror helper, then
|
||||
* assert the dispatch's observable contract: the reply term is the voter's
|
||||
* CURRENT term UNCHANGED (no term adoption) and no persistent state mutates.
|
||||
*
|
||||
* pre_vote_dispatch_decide reproduces cluster.c's OPCode_preVoteRequest decision
|
||||
* byte-for-byte: freshness floor, then raft_on_pre_vote_request, with the reply
|
||||
* term pinned to the voter's current term on every path (grant OR deny). */
|
||||
|
||||
static int pre_vote_dispatch_decide(ha_state_t *ha,
|
||||
uint64_t cand_term,
|
||||
uint64_t cand_tag,
|
||||
uint64_t cand_lsn,
|
||||
uint64_t local_tag,
|
||||
uint64_t local_lsn,
|
||||
int from_idx,
|
||||
uint64_t *out_reply_term,
|
||||
uint64_t *out_my_tag,
|
||||
uint64_t *out_my_lsn)
|
||||
{
|
||||
/* Freshness floor: max(local, learned-committed), identical to the real-vote
|
||||
* dispatch case. */
|
||||
uint64_t my_tag = 0, my_lsn = 0;
|
||||
ha_vote_self_freshness(ha, local_tag, local_lsn, &my_tag, &my_lsn);
|
||||
|
||||
int freshness_ok = !(cand_tag < my_tag ||
|
||||
(cand_tag == my_tag && cand_lsn < my_lsn));
|
||||
|
||||
int granted = 0;
|
||||
if (freshness_ok && from_idx >= 0)
|
||||
granted = (raft_on_pre_vote_request(&ha->raft, from_idx, cand_term)
|
||||
== RAFT_GRANT) ? 1 : 0;
|
||||
|
||||
/* PreVote NEVER adopts the candidate term: the reply carries the voter's
|
||||
* CURRENT term unchanged, on grant OR deny. */
|
||||
if (out_reply_term)
|
||||
*out_reply_term =
|
||||
atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
if (out_my_tag) *out_my_tag = my_tag;
|
||||
if (out_my_lsn) *out_my_lsn = my_lsn;
|
||||
return granted;
|
||||
}
|
||||
|
||||
/* Snapshot of every field the PreVote handler must leave UNCHANGED. */
|
||||
typedef struct {
|
||||
uint64_t term;
|
||||
uint64_t raft_term;
|
||||
uint64_t voted_for_term;
|
||||
char voted_for_name[XCE_MAXNAME_LEN];
|
||||
int raft_voted_for;
|
||||
int32_t role;
|
||||
} prevote_state_snapshot_t;
|
||||
|
||||
static void snapshot_prevote_state(ha_state_t *ha, prevote_state_snapshot_t *s)
|
||||
{
|
||||
s->term = atomic_load_explicit(&ha->term, memory_order_acquire);
|
||||
s->raft_term = ha->raft.term;
|
||||
s->voted_for_term = ha->voted_for_term;
|
||||
memcpy(s->voted_for_name, ha->voted_for_name, sizeof(s->voted_for_name));
|
||||
s->raft_voted_for = ha->raft.voted_for;
|
||||
s->role = atomic_load_explicit(&ha->role, memory_order_acquire);
|
||||
}
|
||||
|
||||
static void assert_prevote_state_unchanged(ha_state_t *ha,
|
||||
const prevote_state_snapshot_t *before)
|
||||
{
|
||||
prevote_state_snapshot_t after;
|
||||
snapshot_prevote_state(ha, &after);
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->term, after.term,
|
||||
"PreVote must NOT change ha->term (no term adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->raft_term, after.raft_term,
|
||||
"PreVote must NOT change raft.term (no term adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(before->voted_for_term, after.voted_for_term,
|
||||
"PreVote must NOT change voted_for_term (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
|
||||
memcmp(before->voted_for_name, after.voted_for_name,
|
||||
sizeof(after.voted_for_name)),
|
||||
"PreVote must NOT change voted_for_name (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(before->raft_voted_for, after.raft_voted_for,
|
||||
"PreVote must NOT change raft.voted_for (records no vote)");
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(before->role, after.role,
|
||||
"PreVote must NOT change role");
|
||||
}
|
||||
|
||||
/* ── Req 3.3: CTRL_PRIMARY denies a PreVote regardless of candidate freshness ─ */
|
||||
|
||||
static void test_pre_vote_primary_role_denies(void)
|
||||
{
|
||||
ha_state_t ha;
|
||||
ha_init(&ha, &g_gate_cfg, 0);
|
||||
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY, memory_order_release);
|
||||
atomic_store_explicit(&ha.term, 5ULL, memory_order_release);
|
||||
ha.raft.term = 5ULL;
|
||||
ha.voted_for_term = 0;
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
5ULL, /* cand_term */
|
||||
100ULL, /* cand_tag — perfectly fresh */
|
||||
50ULL, /* cand_lsn */
|
||||
100ULL, /* local tag */
|
||||
50ULL, /* local lsn */
|
||||
0, /* from_idx */
|
||||
&reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"CTRL_PRIMARY must deny all PreVote requests (Req 3.3)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_primary_role_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 3.1: CTRL_STANDBY + fresh heartbeat denies a fresh PreVote ─────────── */
|
||||
|
||||
static void test_pre_vote_standby_fresh_heartbeat_denies(void)
|
||||
{
|
||||
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, 2ULL, memory_order_release);
|
||||
ha.raft.term = 2ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = 999; /* live primary connection */
|
||||
set_last_activity_age(&ha, 500L); /* fresh heartbeat (< 10 s) */
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
3ULL, /* cand wants term+1 = 3 */
|
||||
100ULL, 50ULL, /* candidate is fresh */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"STANDBY with fresh heartbeat must deny PreVote — leader believed alive (Req 3.1)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(2ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_standby_fresh_heartbeat_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 3.1/3.2: catch-up standby denies a PreVote ────────────────────────── */
|
||||
|
||||
static void test_pre_vote_catch_up_denies(void)
|
||||
{
|
||||
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, 4ULL, memory_order_release);
|
||||
ha.raft.term = 4ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale heartbeat → not sticky */
|
||||
atomic_store_explicit(&ha.in_catch_up, 1, memory_order_release);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
5ULL,
|
||||
100ULL, 50ULL, /* fresh candidate */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"a catching-up standby must deny PreVote (Req 3.x)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(4ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_catch_up_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.2: stale-freshness candidate denied even when stickiness lapsed ──── */
|
||||
|
||||
static void test_pre_vote_stale_freshness_denies(void)
|
||||
{
|
||||
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, 1ULL, memory_order_release);
|
||||
ha.raft.term = 1ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L); /* stale — stickiness lapsed */
|
||||
|
||||
/* Learned-committed floor: config_tag=100. */
|
||||
atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
2ULL,
|
||||
50ULL, /* cand_tag below learned floor 100 */
|
||||
999ULL, /* high lsn — irrelevant */
|
||||
0ULL, /* local cold */
|
||||
0ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"candidate below the freshness floor must be DENIED (Req 2.2)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(1ULL, reply_term,
|
||||
"denied PreVote reply carries the voter's UNCHANGED term (no adoption)");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_stale_freshness_denies: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.3: stale-term candidate denied, no term adoption ─────────────────── */
|
||||
|
||||
static void test_pre_vote_stale_term_denies_no_adoption(void)
|
||||
{
|
||||
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, 5ULL, memory_order_release);
|
||||
ha.raft.term = 5ULL;
|
||||
ha.voted_for_term = 0;
|
||||
ha.primary_ctrl_fd = -1;
|
||||
set_last_activity_age(&ha, 60000L);
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
3ULL, /* cand_term < raft.term (5) → stale */
|
||||
100ULL, 50ULL,
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, NULL, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted,
|
||||
"a stale-term PreVote candidate must be DENIED (Req 2.3)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(5ULL, reply_term,
|
||||
"stale-term PreVote reply carries the voter's UNCHANGED higher term");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_stale_term_denies_no_adoption: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Req 2.1/3.2/5.1: stickiness lapsed + fresh candidate → GRANT, no mutation ─ */
|
||||
|
||||
static void test_pre_vote_granted_when_stickiness_lapsed_and_fresh(void)
|
||||
{
|
||||
/* The genuine-failover case: no live primary, heartbeat stale (stickiness
|
||||
* lapsed), candidate proposes term+1 and meets the freshness floor → GRANT.
|
||||
* The grant must still mutate NOTHING (no voted_for, no term, no role). */
|
||||
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;
|
||||
set_last_activity_age(&ha, 60000L); /* stale — stickiness lapsed */
|
||||
|
||||
prevote_state_snapshot_t before;
|
||||
snapshot_prevote_state(&ha, &before);
|
||||
|
||||
uint64_t reply_term = 0, my_tag = 0, my_lsn = 0;
|
||||
int granted = pre_vote_dispatch_decide(&ha,
|
||||
4ULL, /* prospective term = current+1 */
|
||||
100ULL, 50ULL, /* meets local floor */
|
||||
100ULL, 50ULL,
|
||||
0, &reply_term, &my_tag, &my_lsn);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted,
|
||||
"fresh candidate after stickiness lapse must be GRANTED a PreVote (Req 5.1)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(3ULL, reply_term,
|
||||
"granted PreVote reply STILL carries the voter's UNCHANGED term (no adoption)");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(100ULL, my_tag,
|
||||
"reply must carry the voter's freshness config_tag");
|
||||
TEST_ASSERT_EQUAL_UINT64_MESSAGE(50ULL, my_lsn,
|
||||
"reply must carry the voter's freshness last_log_lsn");
|
||||
assert_prevote_state_unchanged(&ha, &before);
|
||||
|
||||
ha_fini(&ha);
|
||||
printf("test_pre_vote_granted_when_stickiness_lapsed_and_fresh: PASS\n");
|
||||
}
|
||||
|
||||
/* ── Test suite entry point ───────────────────────────────────────────────── */
|
||||
|
||||
void test_ha_vote_gate(void)
|
||||
|
|
@ -456,4 +762,13 @@ void test_ha_vote_gate(void)
|
|||
RUN_TEST(test_learned_committed_lsn_denies_lower_lsn_candidate);
|
||||
RUN_TEST(test_uptodate_candidate_still_granted_with_learned_floor);
|
||||
RUN_TEST(test_no_learned_state_falls_back_to_local_gate);
|
||||
|
||||
/* Task 3.1 — voter-side PreVote dispatch gate (stickiness + freshness,
|
||||
* no mutation, reply term unchanged) */
|
||||
RUN_TEST(test_pre_vote_primary_role_denies);
|
||||
RUN_TEST(test_pre_vote_standby_fresh_heartbeat_denies);
|
||||
RUN_TEST(test_pre_vote_catch_up_denies);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue