feat(raft-library): task 3.4 — raft_on_vote_reply quorum tracking; 6 tests + review fixes

This commit is contained in:
Rongsong Shen 2026-05-31 05:56:34 +00:00
parent e0bcb35554
commit f81054930f
3 changed files with 328 additions and 7 deletions

View file

@ -50,7 +50,7 @@
- Sets `*reply_term = current_term` before every return path
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 3.4 Implement raft_on_vote_reply (quorum tracking and election decision)
- [x] 3.4 Implement raft_on_vote_reply (quorum tracking and election decision)
- Term upgrade: if `reply_term > current_term`, update `current_term`, set `role=RAFT_FOLLOWER`, `election_decided=1`, fire `on_become_follower(ctx, current_term, -1)`, `on_cancel_timeout`, `on_trace(became_follower)`, return `RAFT_BECAME_FOLLOWER`
- No-op guard: if `election_decided`, fire `on_trace(vote_reply_noop)`, return `RAFT_PENDING`
- Increment `replies_received` on every call past the guard (tracks total peer replies)

View file

@ -163,11 +163,63 @@ int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term)
{
(void)s;
(void)from_idx;
(void)granted;
(void)term;
return 0;
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_in", NULL);
/* Step 1: term upgrade — higher term means we lost the election */
if (term > s->term) {
s->term = term;
s->role = RAFT_FOLLOWER;
s->election_decided = 1;
s->cbs->on_become_follower(s->ctx, s->term, -1);
s->cbs->on_cancel_timeout(s->ctx);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "became_follower", NULL);
return RAFT_BECAME_FOLLOWER;
}
/* Step 2: no-op guard — election already decided */
if (s->election_decided) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_noop", NULL);
return RAFT_PENDING;
}
/* Step 3: count this reply */
s->replies_received++;
/* Step 4: accumulate grant */
if (granted)
s->votes_granted++;
/* Step 5: quorum check */
int quorum = (s->n_peers / 2) + 1;
if (s->votes_granted >= quorum) {
s->role = RAFT_LEADER;
s->election_decided = 1;
s->cbs->on_become_leader(s->ctx, s->term);
s->cbs->on_cancel_timeout(s->ctx);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "quorum_reached", NULL);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "became_leader", NULL);
return RAFT_BECAME_LEADER;
}
/* Step 6: all-replied-no-quorum */
if (s->replies_received == s->n_peers - 1 && s->votes_granted < quorum) {
s->role = RAFT_FOLLOWER;
s->election_decided = 1;
s->cbs->on_become_follower(s->ctx, s->term, -1);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "became_follower", NULL);
return RAFT_BECAME_FOLLOWER;
}
/* Step 7: still waiting */
return RAFT_PENDING;
}
void raft_on_election_timeout(raft_state_t *s)

View file

@ -90,6 +90,14 @@ typedef struct {
/* on_trace mock */
int trace_call_count;
char last_trace_event[64];
/* on_become_follower mock */
int become_follower_count;
uint64_t last_become_follower_term;
int last_become_follower_leader_idx;
/* on_cancel_timeout mock */
int cancel_timeout_count;
} mock_cbs_state_t;
static mock_cbs_state_t g_mock;
@ -120,6 +128,20 @@ static void mock_become_leader(void *ctx, uint64_t term)
g_mock.last_become_leader_term = term;
}
static void mock_become_follower(void *ctx, uint64_t term, int leader_idx)
{
(void)ctx;
g_mock.become_follower_count++;
g_mock.last_become_follower_term = term;
g_mock.last_become_follower_leader_idx = leader_idx;
}
static void mock_cancel_timeout(void *ctx)
{
(void)ctx;
g_mock.cancel_timeout_count++;
}
static int mock_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx;
@ -161,10 +183,10 @@ static raft_callbacks_t make_mock_cbs(void)
memset(&cbs, 0, sizeof(cbs));
cbs.on_send_vote_request = mock_send_vote_request;
cbs.on_become_leader = mock_become_leader;
cbs.on_become_follower = stub_become_follower; /* plain stub */
cbs.on_become_follower = mock_become_follower;
cbs.on_persist = mock_persist;
cbs.on_schedule_timeout = mock_schedule_timeout;
cbs.on_cancel_timeout = stub_cancel_timeout;
cbs.on_cancel_timeout = mock_cancel_timeout;
cbs.on_start_election_after_delay = stub_start_election_after_delay;
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
return cbs;
@ -964,6 +986,245 @@ static void test_vote_req_reply_term_always_set(void)
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
}
/* ---------- raft_on_vote_reply tests (task 3.4) ---------- */
/*
* test_vote_reply_higher_term_steps_down
*
* Node is CANDIDATE at term=2. Receives reply with term=5 (higher).
* Expected: RAFT_BECAME_FOLLOWER, role=FOLLOWER, term=5, election_decided=1,
* on_become_follower fired with (5, -1), on_cancel_timeout fired once.
*/
static void test_vote_reply_higher_term_steps_down(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_node(&s, &cbs, 3, 0));
/* Manually put node into CANDIDATE state at term=2 */
s.role = RAFT_CANDIDATE;
s.term = 2;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
int rc = raft_on_vote_reply(&s, 1, 0, 5);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must fire once with (term=5, leader_idx=-1) */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_cancel_timeout must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_leader must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/*
* test_vote_reply_noop_after_decided
*
* election_decided=1 already set. Call raft_on_vote_reply.
* Expected: RAFT_PENDING, no callbacks fire.
*/
static void test_vote_reply_noop_after_decided(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_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 2;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 1; /* already decided */
int rc = raft_on_vote_reply(&s, 1, 1, 2);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
/* No callbacks must fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/*
* test_vote_reply_quorum_3node_1grant
*
* 3-node cluster (quorum=2). Node is CANDIDATE with votes_granted=1 (self-vote).
* One peer grant arrives → votes_granted=2 >= quorum=2.
* Expected: RAFT_BECAME_LEADER, role=LEADER, election_decided=1,
* on_become_leader fired, on_cancel_timeout fired.
*/
static void test_vote_reply_quorum_3node_1grant(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_node(&s, &cbs, 3, 0));
/* Simulate state after raft_start_election: CANDIDATE, self-vote counted */
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* One peer grants */
int rc = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
/* on_become_leader must fire once with the current term */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term);
/* on_cancel_timeout must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_follower must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_vote_reply_no_quorum_all_replied_3node
*
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
* Both peers deny. After second deny: replies_received=2 == n_peers-1=2,
* votes_granted=1 < quorum=2 → RAFT_BECAME_FOLLOWER.
*/
static void test_vote_reply_no_quorum_all_replied_3node(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_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* First deny: replies_received=1, not yet all replied */
int rc1 = raft_on_vote_reply(&s, 1, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
/* Second deny: replies_received=2 == n_peers-1=2 → step down */
int rc2 = raft_on_vote_reply(&s, 2, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc2);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_become_leader must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
/* on_cancel_timeout must NOT fire for all-replied-no-quorum (only term-upgrade and quorum paths cancel) */
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/*
* test_vote_reply_quorum_5node
*
* 5-node cluster (quorum=3). Node starts election (votes_granted=1).
* Two grants arrive → votes_granted=3 = quorum=3 → RAFT_BECAME_LEADER.
*/
static void test_vote_reply_quorum_5node(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_node(&s, &cbs, 5, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* First grant: votes_granted=2, quorum=3 → still pending */
int rc1 = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
/* Second grant: votes_granted=3 = quorum → leader */
int rc2 = raft_on_vote_reply(&s, 2, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc2);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
TEST_ASSERT_EQUAL_INT(3, s.votes_granted);
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_vote_reply_pending_waiting
*
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
* One deny arrives: replies_received=1, n_peers-1=2 → not all replied yet.
* Expected: RAFT_PENDING, no role change, no callbacks.
*/
static void test_vote_reply_pending_waiting(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_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* One deny: replies_received=1, n_peers-1=2 → still waiting */
int rc = raft_on_vote_reply(&s, 1, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* unchanged */
/* No role-change callbacks */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/* ---------- raft_snapshot tests ---------- */
static void test_raft_snapshot_returns_current_state(void)
@ -1025,6 +1286,14 @@ void test_raft(void)
RUN_TEST(test_start_election_already_candidate);
RUN_TEST(test_start_election_already_leader);
/* raft_on_vote_reply (task 3.4) */
RUN_TEST(test_vote_reply_higher_term_steps_down);
RUN_TEST(test_vote_reply_noop_after_decided);
RUN_TEST(test_vote_reply_quorum_3node_1grant);
RUN_TEST(test_vote_reply_no_quorum_all_replied_3node);
RUN_TEST(test_vote_reply_quorum_5node);
RUN_TEST(test_vote_reply_pending_waiting);
/* raft_on_vote_request (task 3.3) */
RUN_TEST(test_vote_req_stale_term);
RUN_TEST(test_vote_req_higher_term_updates_state);