feat(ha-prevote): implement pure PreVote reply tally (task 2.3)

raft_on_pre_vote_reply: per-peer-deduped tally returning WON at quorum
((n_peers/2)+1), LOST when all non-self peers reply without quorum, else
PENDING; bounds-guards peer_idx. Never fires become-leader/follower, never
mutates term/voted_for/role, never persists. 8 RED-first tests (3- and 5-node,
dedup, out-of-range, purity). Raft PreVote pure core complete.

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

View file

@ -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;

View file

@ -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);