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:
parent
6db912b2a9
commit
9fac681510
3 changed files with 305 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue