diff --git a/.kiro/specs/ha-prevote/tasks.md b/.kiro/specs/ha-prevote/tasks.md index 3da81756..fb568e24 100644 --- a/.kiro/specs/ha-prevote/tasks.md +++ b/.kiro/specs/ha-prevote/tasks.md @@ -30,7 +30,7 @@ - _Boundary: raft pre-vote functions_ - _Depends: 1.2_ -- [ ] 2.3 Implement the PreVote reply tally +- [x] 2.3 Implement the PreVote reply tally - RED first: add raft unit tests asserting the tally lives on the raft state, returns won at quorum, lost when all peers reply without quorum, pending otherwise, and ignores duplicate replies from the same peer. - Mirror the production real-vote tally pattern, but never fire become-leader/become-follower — return a status the caller acts on. - Observable: test confirms role and term unchanged across won/lost/pending outcomes. diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index 4d2ad5de..b9fcfd19 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -268,6 +268,73 @@ int raft_begin_pre_vote(raft_state_t *s) return RAFT_OK; } +int raft_on_pre_vote_reply(raft_state_t *s, int peer_idx, int granted) +{ + /* Pure straw-poll tally — mirrors raft_on_vote_reply's quorum and + * all-replied-no-quorum logic, but STRIPS every side effect. Per the + * header INVARIANT (Req 1.4, 1.5, 6.2) this function MUST NOT fire + * on_become_leader / on_become_follower, MUST NOT touch s->term / + * s->voted_for / s->voted_for_term / s->role, and MUST NOT call on_persist. + * It only writes the pre_vote_* tally fields and returns a status the + * controller (task 4.1) acts on. There is no term in a pre-vote reply, so + * no term adoption is even possible here. */ + + /* Bounds guard: ignore replies from an out-of-range peer (mirrors the + * per-peer dedup array bound). A self reply (peer_idx == self_idx) is + * never expected — self is granted by raft_begin_pre_vote — and would also + * be filtered by the already-set flag, but the range check alone is the + * defensive boundary. */ + if (peer_idx < 0 || peer_idx >= s->n_peers || peer_idx >= RAFT_MAX_PEERS) { + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "pre_vote_reply_out_of_range", NULL); + return RAFT_PRE_VOTE_PENDING; + } + + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "pre_vote_reply_in", NULL); + + /* Per-peer idempotency: a duplicate reply from a peer we already counted is + * ignored (no double-count). Stay undecided. */ + if (s->pre_vote_replies[peer_idx]) { + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "pre_vote_reply_dup", NULL); + return RAFT_PRE_VOTE_PENDING; + } + s->pre_vote_replies[peer_idx] = 1; + + /* Accumulate grant. pre_votes_granted already includes the self-grant set + * by raft_begin_pre_vote. */ + if (granted) + s->pre_votes_granted++; + + /* Quorum mirrors raft_on_vote_reply: n_peers is the TOTAL cluster size + * including self. */ + int quorum = (s->n_peers / 2) + 1; + if (s->pre_votes_granted >= quorum) { + s->pre_vote_in_progress = 0; + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "pre_vote_won", NULL); + return RAFT_PRE_VOTE_WON; + } + + /* All non-self peers have replied without reaching quorum → lost. Count + * the set per-peer flags (a self reply, if it ever arrived, would have been + * range-filtered out of n_peers - 1 expectation only if self_idx is in + * range, so compare against n_peers - 1 replied flags). */ + int replied = 0; + for (int i = 0; i < s->n_peers; i++) + replied += s->pre_vote_replies[i]; + if (replied == s->n_peers - 1) { + s->pre_vote_in_progress = 0; + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "pre_vote_lost", NULL); + return RAFT_PRE_VOTE_LOST; + } + + /* Still awaiting replies. */ + return RAFT_PRE_VOTE_PENDING; +} + 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 5ffc6e3e..8aa688f9 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -3327,6 +3327,233 @@ static void test_begin_pre_vote_null_send_callback_suppressed(void) TEST_ASSERT_EQUAL_UINT64(5, s.term); } +/* ---------- raft_on_pre_vote_reply tally (task 2.3) ---------- */ + +/* + * Helper: arm a pre-vote round on an n-node cluster at the given term. + * Mirrors what raft_begin_pre_vote sets up (self-grant, in-progress), so the + * tally tests can feed replies without depending on the send transport. + */ +static int arm_pre_vote_round(raft_state_t *s, raft_callbacks_t *cbs, + int n_peers, int self_idx, uint64_t term) +{ + int rc = init_vote_node(s, cbs, n_peers, self_idx, term); + if (rc != 0) + return rc; + /* Suppress sends; we drive replies directly. */ + raft_begin_pre_vote(s); + return 0; +} + +/* + * test_pre_vote_reply_won_at_quorum + * + * 3-node cluster (quorum=2), self-grant already counted. One peer grants → + * the round is WON. Tally lives on the raft state; in-progress is cleared. + */ +static void test_pre_vote_reply_won_at_quorum(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5)); + /* begin sent to peers; clear send counter so it does not confuse asserts. */ + + int rc = raft_on_pre_vote_reply(&s, 1, 1 /* granted */); + + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc); + TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */ + TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress); /* round closed */ + + /* CRITICAL: a win must NEVER fire leadership callbacks. */ + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count); +} + +/* + * test_pre_vote_reply_lost_when_all_reply_no_quorum + * + * 3-node cluster. Both peers deny. After all non-self peers reply without + * quorum, the round is LOST. + */ +static void test_pre_vote_reply_lost_when_all_reply_no_quorum(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5)); + + int rc1 = raft_on_pre_vote_reply(&s, 1, 0 /* deny */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1); /* one peer still out */ + + int rc2 = raft_on_pre_vote_reply(&s, 2, 0 /* deny */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc2); /* all replied, no quorum */ + + TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */ + TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress); + + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count); +} + +/* + * test_pre_vote_reply_pending_when_undecided + * + * 3-node cluster. One peer denies, the other has not replied yet → PENDING + * (quorum still reachable). + */ +static void test_pre_vote_reply_pending_when_undecided(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5)); + + int rc = raft_on_pre_vote_reply(&s, 1, 0 /* deny */); + + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc); + TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */ + TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress); /* still open */ +} + +/* + * test_pre_vote_reply_duplicate_ignored + * + * A duplicate reply from the same peer must not double-count. A grant from + * peer 1, then a second grant from peer 1, leaves the tally at 2 (self + one + * grant). In a 5-node cluster (quorum=3) that keeps the round PENDING. + */ +static void test_pre_vote_reply_duplicate_ignored(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5)); + + int rc1 = raft_on_pre_vote_reply(&s, 1, 1 /* grant */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1); + TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */ + + /* Duplicate grant from peer 1 — must be ignored. */ + int rc2 = raft_on_pre_vote_reply(&s, 1, 1 /* grant again */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc2); + TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* unchanged: no double-count */ + TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress); +} + +/* + * test_pre_vote_reply_won_purity_state_unchanged + * + * On WON: term, voted_for, voted_for_term, role are all unchanged and no + * persist / leadership callback fires. + */ +static void test_pre_vote_reply_won_purity_state_unchanged(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5)); + /* A real vote cast earlier in this term must survive the tally. */ + s.voted_for = 2; + s.voted_for_term = 5; + + int rc = raft_on_pre_vote_reply(&s, 1, 1 /* grant */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc); + + 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); + + 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_pre_vote_reply_lost_purity_state_unchanged + * + * On LOST: term / voted_for / role unchanged; no leadership callback. + */ +static void test_pre_vote_reply_lost_purity_state_unchanged(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 7)); + s.voted_for = 1; + s.voted_for_term = 7; + + raft_on_pre_vote_reply(&s, 1, 0 /* deny */); + int rc = raft_on_pre_vote_reply(&s, 2, 0 /* deny */); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc); + + TEST_ASSERT_EQUAL_UINT64(7, s.term); + TEST_ASSERT_EQUAL_INT(1, s.voted_for); + TEST_ASSERT_EQUAL_UINT64(7, s.voted_for_term); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + + 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_pre_vote_reply_out_of_range_peer_ignored + * + * A peer_idx outside [0, n_peers) (or >= RAFT_MAX_PEERS) is ignored: no tally + * change, round stays in progress, returns PENDING. + */ +static void test_pre_vote_reply_out_of_range_peer_ignored(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5)); + + int rc_neg = raft_on_pre_vote_reply(&s, -1, 1 /* grant */); + int rc_over = raft_on_pre_vote_reply(&s, 3, 1 /* grant */); /* == n_peers */ + int rc_max = raft_on_pre_vote_reply(&s, RAFT_MAX_PEERS, 1); + + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_neg); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_over); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_max); + + TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only, no spurious count */ + TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress); +} + +/* + * test_pre_vote_reply_5node_won_at_three + * + * 5-node cluster (quorum=3): self + two granting peers → WON. + */ +static void test_pre_vote_reply_5node_won_at_three(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5)); + + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, + raft_on_pre_vote_reply(&s, 1, 1)); + TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, + raft_on_pre_vote_reply(&s, 2, 1)); + + TEST_ASSERT_EQUAL_INT(3, s.pre_votes_granted); + TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count); +} + /* ---------- test_raft entry point ---------- */ void test_raft(void) @@ -3397,6 +3624,16 @@ void test_raft(void) RUN_TEST(test_begin_pre_vote_single_node_wins); RUN_TEST(test_begin_pre_vote_null_send_callback_suppressed); + /* raft_on_pre_vote_reply tally (task 2.3) */ + RUN_TEST(test_pre_vote_reply_won_at_quorum); + RUN_TEST(test_pre_vote_reply_lost_when_all_reply_no_quorum); + RUN_TEST(test_pre_vote_reply_pending_when_undecided); + RUN_TEST(test_pre_vote_reply_duplicate_ignored); + RUN_TEST(test_pre_vote_reply_won_purity_state_unchanged); + RUN_TEST(test_pre_vote_reply_lost_purity_state_unchanged); + RUN_TEST(test_pre_vote_reply_out_of_range_peer_ignored); + RUN_TEST(test_pre_vote_reply_5node_won_at_three); + /* simulator init (task 4.1) */ #ifdef XCE_TESTING RUN_TEST(test_sim_init_3node);