feat(ha-prevote): implement pure voter-side PreVote decision (task 2.1)
raft_on_pre_vote_request: deny on stale term, else reuse on_vote_eligible (primary/heartbeat-stickiness/catch-up gates). Never mutates term/voted_for/ role and never persists or adopts a higher term. 6 RED-first raft unit tests assert the purity invariant; libxce suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
449b8e9e93
commit
722fee5323
3 changed files with 249 additions and 1 deletions
|
|
@ -2920,6 +2920,207 @@ static void test_proto_G2_partition_minority_no_quorum(void)
|
|||
|
||||
#endif /* XCE_TESTING */
|
||||
|
||||
/* ---------- raft_on_pre_vote_request tests (task 2.1) ---------- */
|
||||
|
||||
/*
|
||||
* raft_on_pre_vote_request is the voter-side PreVote decision. It mirrors
|
||||
* raft_on_vote_request's Step 1 (stale-term deny) and Step 5 (on_vote_eligible
|
||||
* gate) but strips ALL persistent mutation: it must never touch term,
|
||||
* voted_for, voted_for_term, role, and must never call on_persist. Freshness is
|
||||
* applied by the dispatcher BEFORE this call (same contract as the real vote).
|
||||
*/
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_grant_higher_term_eligible
|
||||
*
|
||||
* Node at term=5 (FOLLOWER, no vote), receives PreVote for cand_term=6 with the
|
||||
* eligibility callback granting (return 1). Expected: grant (1), and term /
|
||||
* voted_for / voted_for_term / role all unchanged, on_persist NOT called.
|
||||
*/
|
||||
static void test_pre_vote_req_grant_higher_term_eligible(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
cbs.on_vote_eligible = mock_vote_eligible;
|
||||
reset_mock();
|
||||
g_mock.vote_eligible_return_value = 1; /* eligibility grants */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
|
||||
int rc = raft_on_pre_vote_request(&s, 1, 6);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||||
|
||||
/* PURITY: nothing persistent may change on a granted PreVote */
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
|
||||
/* eligibility gate was consulted */
|
||||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_grant_equal_term_eligible
|
||||
*
|
||||
* Equal-term candidate (cand_term == s->term) must NOT be denied by the
|
||||
* stale-term gate (deny is only for cand_term < term), and is granted when
|
||||
* eligibility allows. Purity invariant still holds.
|
||||
*/
|
||||
static void test_pre_vote_req_grant_equal_term_eligible(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
cbs.on_vote_eligible = mock_vote_eligible;
|
||||
reset_mock();
|
||||
g_mock.vote_eligible_return_value = 1;
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
|
||||
int rc = raft_on_pre_vote_request(&s, 1, 5); /* equal term */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_deny_stale_term
|
||||
*
|
||||
* Node at term=5, receives PreVote for cand_term=4 (< term). Expected: deny (0)
|
||||
* WITHOUT consulting on_vote_eligible, no term-adoption, no persist.
|
||||
*/
|
||||
static void test_pre_vote_req_deny_stale_term(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
cbs.on_vote_eligible = mock_vote_eligible;
|
||||
reset_mock();
|
||||
g_mock.vote_eligible_return_value = 1; /* would grant if consulted */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
|
||||
int rc = raft_on_pre_vote_request(&s, 1, 4); /* stale */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||||
|
||||
/* Stale-term denial short-circuits BEFORE the eligibility gate. */
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
|
||||
|
||||
/* PURITY: term must NOT be adopted, no persist. */
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_deny_eligibility_rejects
|
||||
*
|
||||
* Fresh, higher-term candidate but the eligibility callback rejects (returns 0)
|
||||
* — e.g. primary-deny / fresh-heartbeat stickiness / catch-up. Expected: deny,
|
||||
* with the eligibility gate consulted, and the purity invariant preserved.
|
||||
*/
|
||||
static void test_pre_vote_req_deny_eligibility_rejects(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
cbs.on_vote_eligible = mock_vote_eligible;
|
||||
reset_mock();
|
||||
g_mock.vote_eligible_return_value = 0; /* eligibility denies */
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
|
||||
int rc = raft_on_pre_vote_request(&s, 1, 6);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
|
||||
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_null_eligible_grants
|
||||
*
|
||||
* When on_vote_eligible is NULL, a fresh higher-or-equal-term candidate is
|
||||
* granted (all Raft-eligible candidates granted), with no mutation/persist.
|
||||
*/
|
||||
static void test_pre_vote_req_null_eligible_grants(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
/* on_vote_eligible deliberately left NULL */
|
||||
reset_mock();
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
|
||||
int rc = raft_on_pre_vote_request(&s, 1, 6);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
}
|
||||
|
||||
/*
|
||||
* test_pre_vote_req_grant_preserves_existing_vote (Req 2.4)
|
||||
*
|
||||
* A node that has already voted for someone in the current term may still GRANT
|
||||
* a PreVote (PreVote does not consume / change the recorded vote). Confirms the
|
||||
* existing voted_for / voted_for_term are untouched by a granted PreVote.
|
||||
*/
|
||||
static void test_pre_vote_req_grant_preserves_existing_vote(void)
|
||||
{
|
||||
raft_state_t s;
|
||||
raft_callbacks_t cbs = make_mock_cbs();
|
||||
|
||||
cbs.on_trace = mock_trace;
|
||||
cbs.on_vote_eligible = mock_vote_eligible;
|
||||
reset_mock();
|
||||
g_mock.vote_eligible_return_value = 1;
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
|
||||
/* Node already cast its real vote for peer 1 in term 5. */
|
||||
s.voted_for = 1;
|
||||
s.voted_for_term = 5;
|
||||
|
||||
/* A different candidate (peer 2) probes a PreVote for term 6. */
|
||||
int rc = raft_on_pre_vote_request(&s, 2, 6);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
|
||||
|
||||
/* The recorded real vote must be untouched (no consume, no overwrite). */
|
||||
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
|
||||
TEST_ASSERT_EQUAL_UINT64(5, s.term);
|
||||
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
|
||||
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
|
||||
}
|
||||
|
||||
/* ---------- test_raft entry point ---------- */
|
||||
|
||||
void test_raft(void)
|
||||
|
|
@ -2974,6 +3175,14 @@ void test_raft(void)
|
|||
RUN_TEST(test_check_and_start_not_voted);
|
||||
RUN_TEST(test_check_and_start_voted_for_self);
|
||||
|
||||
/* raft_on_pre_vote_request (task 2.1) */
|
||||
RUN_TEST(test_pre_vote_req_grant_higher_term_eligible);
|
||||
RUN_TEST(test_pre_vote_req_grant_equal_term_eligible);
|
||||
RUN_TEST(test_pre_vote_req_deny_stale_term);
|
||||
RUN_TEST(test_pre_vote_req_deny_eligibility_rejects);
|
||||
RUN_TEST(test_pre_vote_req_null_eligible_grants);
|
||||
RUN_TEST(test_pre_vote_req_grant_preserves_existing_vote);
|
||||
|
||||
/* simulator init (task 4.1) */
|
||||
#ifdef XCE_TESTING
|
||||
RUN_TEST(test_sim_init_3node);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue