diff --git a/.kiro/specs/ha-prevote/tasks.md b/.kiro/specs/ha-prevote/tasks.md index 307b8c8..3da8175 100644 --- a/.kiro/specs/ha-prevote/tasks.md +++ b/.kiro/specs/ha-prevote/tasks.md @@ -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_ diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index fbef39d..4d2ad5d 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -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; diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index 082b927..5ffc6e3 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -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);