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:
parent
722fee5323
commit
6db912b2a9
3 changed files with 254 additions and 1 deletions
|
|
@ -23,7 +23,7 @@
|
|||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 6.2_
|
||||
- _Boundary: raft pre-vote functions_
|
||||
|
||||
- [ ] 2.2 Implement the candidate-side PreVote start
|
||||
- [x] 2.2 Implement the candidate-side PreVote start
|
||||
- RED first: add a raft unit test asserting the prospective term used is current+1, that it is never written to the persistent term and never persisted, and that the send-pre-vote callback fires once per peer with a self-grant recorded.
|
||||
- Observable: test confirms the persistent term/voted-for are byte-for-byte unchanged after starting the round.
|
||||
- _Requirements: 1.1, 1.2, 1.3, 6.2_
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue