feat(ha-prevote): implement pure candidate-side PreVote start (task 2.2)

raft_begin_pre_vote: compute prospective term (term+1) locally without
persisting, arm the pre_vote_* tally with a self-grant, NULL-safely probe each
non-self peer, return RAFT_PRE_VOTE_WON for single-node else RAFT_OK. Stays
FOLLOWER, schedules no timeout, mutates no persistent state. 6 RED-first tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-06-16 04:33:53 +00:00
parent 722fee5323
commit 6db912b2a9
3 changed files with 254 additions and 1 deletions

View file

@ -229,6 +229,45 @@ int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term)
return RAFT_GRANT;
}
int raft_begin_pre_vote(raft_state_t *s)
{
/* Pre-image of raft_start_election: probe peers + tally a self-grant, but
* STRIP every persistent side effect. Per the header INVARIANT (Req 1.2,
* 1.3, 6.2) this function MUST NOT touch s->term, s->voted_for,
* s->voted_for_term, or s->role, and MUST NOT call on_persist. The node
* stays FOLLOWER throughout the round; CANDIDATE happens later in the
* unchanged raft_start_election once the PreVote round is WON. */
/* Prospective term is a LOCAL value only — never assigned to s->term and
* never persisted (this is the whole point of PreVote). */
uint64_t prospective_term = s->term + 1;
/* Reset and arm the round tally on the raft state. Self always grants. */
s->pre_vote_in_progress = 1;
s->pre_votes_granted = 1; /* self-grant */
memset(s->pre_vote_replies, 0, sizeof(s->pre_vote_replies));
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "pre_vote_start", NULL);
/* Single-node: no peers, the self-grant is already a majority. Win
* immediately and let the caller proceed to raft_start_election. */
if (s->n_peers == 1)
return RAFT_PRE_VOTE_WON;
/* Probe every peer except self with the prospective term (NULL-checked:
* if the callback is unset, the caller owns the transport). */
if (s->cbs->on_send_pre_vote_request) {
for (int i = 0; i < s->n_peers; i++) {
if (i == s->self_idx)
continue;
s->cbs->on_send_pre_vote_request(s->ctx, i, prospective_term);
}
}
return RAFT_OK;
}
int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term)
{
(void)from_idx;

View file

@ -102,6 +102,14 @@ typedef struct {
/* on_start_election_after_delay mock (task 3.5) */
int start_election_after_delay_count;
uint32_t last_start_election_delay;
/* on_send_pre_vote_request mock (task 2.2) */
int send_pre_vote_request_count;
int last_pre_vote_peer_idx;
uint64_t last_pre_vote_term;
/* per-peer prospective term recorded by index (up to 8) */
uint64_t pre_vote_sent_term[8]; /* term sent to peer i; 0 = not sent */
int pre_vote_sent_peers[8]; /* ordered list of peer indices sent to */
} mock_cbs_state_t;
static mock_cbs_state_t g_mock;
@ -153,6 +161,20 @@ static void mock_start_election_after_delay(void *ctx, uint32_t delay_ms)
g_mock.last_start_election_delay = delay_ms;
}
static int mock_send_pre_vote_request(void *ctx, int peer_idx,
uint64_t prospective_term)
{
(void)ctx;
if (g_mock.send_pre_vote_request_count < 8)
g_mock.pre_vote_sent_peers[g_mock.send_pre_vote_request_count] = peer_idx;
if (peer_idx >= 0 && peer_idx < 8)
g_mock.pre_vote_sent_term[peer_idx] = prospective_term;
g_mock.last_pre_vote_peer_idx = peer_idx;
g_mock.last_pre_vote_term = prospective_term;
g_mock.send_pre_vote_request_count++;
return 0;
}
static int mock_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx;
@ -199,6 +221,7 @@ static raft_callbacks_t make_mock_cbs(void)
cbs.on_schedule_timeout = mock_schedule_timeout;
cbs.on_cancel_timeout = mock_cancel_timeout;
cbs.on_start_election_after_delay = mock_start_election_after_delay;
cbs.on_send_pre_vote_request = mock_send_pre_vote_request;
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
return cbs;
}
@ -3121,6 +3144,189 @@ static void test_pre_vote_req_grant_preserves_existing_vote(void)
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/* ---------- raft_begin_pre_vote tests (task 2.2) ---------- */
/*
* raft_begin_pre_vote is the candidate-side PreVote start. It is the pre-image
* of raft_start_election: it sends a probe to each peer and tallies a self-grant,
* but STRIPS the term++/persist/role-change/timeout. Per Req 1.2/1.3/6.2 it must
* NOT modify s->term, s->voted_for, s->voted_for_term, s->role, and must NOT call
* on_persist. The prospective term (term+1) is a LOCAL value only.
*/
/*
* test_begin_pre_vote_uses_prospective_term_not_persisted
*
* 3-node FOLLOWER at term=5. raft_begin_pre_vote must send PreVote requests
* carrying prospective_term = term+1 = 6 to each peer, while s->term itself
* stays 5 and on_persist never fires.
*/
static void test_begin_pre_vote_uses_prospective_term_not_persisted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
int rc = raft_begin_pre_vote(&s);
/* Round started (multi-node, not single) */
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* Each peer probe carried prospective term = term+1 = 6 */
TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_pre_vote_term);
/* PURITY: persistent term is unchanged (still 5) and never persisted */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_begin_pre_vote_sends_once_per_non_self_peer
*
* 3-node FOLLOWER, self=1. The send-pre-vote callback must fire exactly once
* for each peer != self (peers 0 and 2), never for self.
*/
static void test_begin_pre_vote_sends_once_per_non_self_peer(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 1, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* Exactly n_peers-1 = 2 sends */
TEST_ASSERT_EQUAL_INT(2, g_mock.send_pre_vote_request_count);
/* Self (idx 1) must NOT be probed; peers 0 and 2 must each be probed once. */
TEST_ASSERT_EQUAL_UINT64(0, g_mock.pre_vote_sent_term[1]); /* self untouched */
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[0]);
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[2]);
/* None of the ordered sent peers is self_idx=1 */
for (int i = 0; i < g_mock.send_pre_vote_request_count; i++)
TEST_ASSERT_NOT_EQUAL(1, g_mock.pre_vote_sent_peers[i]);
}
/*
* test_begin_pre_vote_self_grant_and_in_progress
*
* After begin, the PreVote tally must show in_progress=1 and a single self-grant
* (pre_votes_granted=1). No real election timeout is scheduled here (the
* controller arms the round timeout separately).
*/
static void test_begin_pre_vote_self_grant_and_in_progress(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self-grant only */
/* No election-timeout scheduled by begin_pre_vote (controller owns it). */
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
}
/*
* test_begin_pre_vote_purity_no_persistent_mutation
*
* Byte-for-byte: term, voted_for, voted_for_term, role unchanged; no persist,
* no role transition to CANDIDATE, no become_leader/become_follower.
*/
static void test_begin_pre_vote_purity_no_persistent_mutation(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
/* Node already cast a real vote for peer 2 in term 5 — must survive. */
s.voted_for = 2;
s.voted_for_term = 5;
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* PURITY: every persistent field untouched */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(2, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stays FOLLOWER, not CANDIDATE */
/* No persistence / no leadership-transition side effects */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_begin_pre_vote_single_node_wins
*
* 1-node cluster: no peers, the self-grant is already a majority, so the round
* is won immediately. Returns RAFT_PRE_VOTE_WON, sends nothing, and still
* mutates no persistent state.
*/
static void test_begin_pre_vote_single_node_wins(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 1, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
/* No peer probes for a single-node cluster */
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
/* Self-grant recorded as a majority */
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
/* PURITY even on the WON short-circuit */
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_begin_pre_vote_null_send_callback_suppressed
*
* If on_send_pre_vote_request is NULL, begin must still run (the caller owns
* transport) without crashing, recording the self-grant, and emitting no sends.
*/
static void test_begin_pre_vote_null_send_callback_suppressed(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
cbs.on_send_pre_vote_request = NULL; /* suppressed */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
}
/* ---------- test_raft entry point ---------- */
void test_raft(void)
@ -3183,6 +3389,14 @@ void test_raft(void)
RUN_TEST(test_pre_vote_req_null_eligible_grants);
RUN_TEST(test_pre_vote_req_grant_preserves_existing_vote);
/* raft_begin_pre_vote (task 2.2) */
RUN_TEST(test_begin_pre_vote_uses_prospective_term_not_persisted);
RUN_TEST(test_begin_pre_vote_sends_once_per_non_self_peer);
RUN_TEST(test_begin_pre_vote_self_grant_and_in_progress);
RUN_TEST(test_begin_pre_vote_purity_no_persistent_mutation);
RUN_TEST(test_begin_pre_vote_single_node_wins);
RUN_TEST(test_begin_pre_vote_null_send_callback_suppressed);
/* simulator init (task 4.1) */
#ifdef XCE_TESTING
RUN_TEST(test_sim_init_3node);