feat(raft-library): task 3.3 — raft_on_vote_request 6-step decision; 10 tests

This commit is contained in:
Rongsong Shen 2026-05-31 05:44:34 +00:00
parent 4955fa3805
commit e0bcb35554
3 changed files with 497 additions and 15 deletions

View file

@ -40,7 +40,7 @@
- Fires `on_trace` events: `election_start`, `self_vote`, `term_update` if NULL-safe checks pass
- _Requirements: 2.1, 2.2, 2.3, 6.1_
- [ ] 3.3 Implement raft_on_vote_request (incoming RequestVote processing)
- [x] 3.3 Implement raft_on_vote_request (incoming RequestVote processing)
- Stale term: `term < current_term` → set `*reply_term`, fire `on_trace(vote_denied_stale_term)`, return `RAFT_DENY`
- Term upgrade: `term > current_term` → update `current_term=term`, clear `voted_for=-1`, set `role=RAFT_FOLLOWER`, fire `on_trace(term_update)`
- Role gate: if `role == RAFT_LEADER` → return `RAFT_DENY` (fire `on_trace(vote_denied_role)`); note: COMMITTING state is delegated to `on_vote_eligible` in the adapter

View file

@ -101,11 +101,64 @@ int raft_start_election(raft_state_t *s)
int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
uint64_t *reply_term)
{
(void)s;
(void)from_idx;
(void)term;
(void)reply_term;
return 0;
/* Step 1: stale term — deny immediately, no state change */
if (term < s->term) {
*reply_term = s->term;
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_denied_stale_term", NULL);
return RAFT_DENY;
}
/* Step 2: term upgrade — update term, clear vote, step down to FOLLOWER */
if (term > s->term) {
s->term = term;
s->voted_for = -1;
s->voted_for_term = 0;
s->role = RAFT_FOLLOWER;
}
/* reply_term reflects our (possibly updated) current term from here on */
*reply_term = s->term;
/* Step 3: role gate — LEADER denies */
if (s->role == RAFT_LEADER) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_denied_role", NULL);
return RAFT_DENY;
}
/* Step 4: duplicate-vote deny — already voted for a *different* candidate
* this same term. If voted_for == from_idx and voted_for_term == term,
* fall through to idempotent re-grant path below. */
if (s->voted_for != -1 &&
s->voted_for != from_idx &&
s->voted_for_term == term) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_denied_already_voted", NULL);
return RAFT_DENY;
}
/* Step 5: application-level policy gate (freshness, log lsn, etc.) */
if (s->cbs->on_vote_eligible != NULL) {
if (s->cbs->on_vote_eligible(s->ctx, from_idx, term) == 0) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_denied_freshness", NULL);
return RAFT_DENY;
}
}
/* Step 6: persist before granting — durability guarantee */
if (s->cbs->on_persist(s->ctx, term, from_idx) != 0) {
/* Persist failed — deny without updating voted_for */
return RAFT_DENY;
}
/* Grant: record vote */
s->voted_for = from_idx;
s->voted_for_term = term;
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_granted", NULL);
return RAFT_GRANT;
}
int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term)

View file

@ -66,21 +66,30 @@ static raft_callbacks_t make_full_cbs(void)
/* Per-test counters — reset by each test before calling start_election */
typedef struct {
int send_vote_request_count;
int last_send_peer_idx;
int send_vote_request_count;
int last_send_peer_idx;
uint64_t last_send_term;
/* track each peer index sent (up to 8 for test purposes) */
int sent_peers[8];
int sent_peers[8];
int schedule_timeout_count;
int schedule_timeout_count;
uint32_t last_schedule_delay;
int persist_count;
int persist_count;
uint64_t last_persist_term;
int last_persist_voted_for;
int last_persist_voted_for;
int persist_return_value; /* configurable: 0=success, non-zero=fail */
int become_leader_count;
int become_leader_count;
uint64_t last_become_leader_term;
/* on_vote_eligible mock */
int vote_eligible_return_value; /* configurable: 1=grant, 0=deny; default 1 */
int vote_eligible_call_count;
/* on_trace mock */
int trace_call_count;
char last_trace_event[64];
} mock_cbs_state_t;
static mock_cbs_state_t g_mock;
@ -88,6 +97,9 @@ static mock_cbs_state_t g_mock;
static void reset_mock(void)
{
memset(&g_mock, 0, sizeof(g_mock));
/* Defaults: persist succeeds, vote_eligible grants */
g_mock.persist_return_value = 0;
g_mock.vote_eligible_return_value = 1;
}
static int mock_send_vote_request(void *ctx, int peer_idx, uint64_t term)
@ -114,7 +126,7 @@ static int mock_persist(void *ctx, uint64_t term, int voted_for)
g_mock.persist_count++;
g_mock.last_persist_term = term;
g_mock.last_persist_voted_for = voted_for;
return 0;
return g_mock.persist_return_value;
}
static void mock_schedule_timeout(void *ctx, uint32_t delay_ms)
@ -124,6 +136,24 @@ static void mock_schedule_timeout(void *ctx, uint32_t delay_ms)
g_mock.last_schedule_delay = delay_ms;
}
static int mock_vote_eligible(void *ctx, int from_idx, uint64_t term)
{
(void)ctx; (void)from_idx; (void)term;
g_mock.vote_eligible_call_count++;
return g_mock.vote_eligible_return_value;
}
static void mock_trace(void *ctx, const char *event, const char *detail)
{
(void)ctx; (void)detail;
g_mock.trace_call_count++;
if (event) {
strncpy(g_mock.last_trace_event, event,
sizeof(g_mock.last_trace_event) - 1);
g_mock.last_trace_event[sizeof(g_mock.last_trace_event) - 1] = '\0';
}
}
/* Build callbacks that record invocations in g_mock */
static raft_callbacks_t make_mock_cbs(void)
{
@ -136,7 +166,7 @@ static raft_callbacks_t make_mock_cbs(void)
cbs.on_schedule_timeout = mock_schedule_timeout;
cbs.on_cancel_timeout = stub_cancel_timeout;
cbs.on_start_election_after_delay = stub_start_election_after_delay;
/* on_vote_eligible and on_trace left NULL */
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
return cbs;
}
@ -547,6 +577,393 @@ static void test_start_election_already_leader(void)
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/* ---------- raft_on_vote_request tests (task 3.3) ---------- */
/*
* Helper: init a 3-node node with mock callbacks (including trace).
* term, role, voted_for can be set after init.
*/
static int init_vote_node(raft_state_t *s, raft_callbacks_t *cbs,
int n_peers, int self_idx, uint64_t initial_term)
{
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = n_peers;
cfg.self_idx = self_idx;
cfg.election_timeout_ms = 1000;
cfg.initial_term = initial_term;
cfg.initial_voted_for = -1;
cfg.cbs = cbs;
return raft_init(s, &cfg);
}
/*
* test_vote_req_stale_term
*
* Node at term=5, receives request with term=3.
* Expected: RAFT_DENY, *reply_term=5, state unchanged,
* on_trace fired with "vote_denied_stale_term".
*/
static void test_vote_req_stale_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
/* Confirm starting state */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
int rc = raft_on_vote_request(&s, 1, 3, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* State must be unchanged */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* on_trace must fire with "vote_denied_stale_term" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_stale_term", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_higher_term_updates_state
*
* Node at term=2, receives request with term=5 (higher).
* Expected: s->term updated to 5, voted_for cleared to -1, role=FOLLOWER.
* Since voted_for is unset and persist succeeds: RAFT_GRANT.
* reply_term = 5.
*/
static void test_vote_req_higher_term_updates_state(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 2));
/* Simulate node was CANDIDATE in term=2 */
s.role = RAFT_CANDIDATE;
s.voted_for = 0; /* voted for self */
s.voted_for_term = 2;
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
/* Term upgrade must happen: term updated, role stepped down */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* Then grant because voted_for is cleared and persist succeeds */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* on_persist must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(1, g_mock.last_persist_voted_for);
/* voted_for set on grant */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
}
/*
* test_vote_req_same_term_already_voted_different
*
* Node at term=5, voted_for=1, voted_for_term=5.
* Receives request from idx=2 at term=5.
* Expected: RAFT_DENY, "vote_denied_already_voted" trace.
*/
static void test_vote_req_same_term_already_voted_different(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1;
s.voted_for_term = 5;
int rc = raft_on_vote_request(&s, 2, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* State: voted_for still 1 (not 2), term unchanged */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
/* on_trace fires with already_voted */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_already_voted", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_same_term_idempotent_same_candidate
*
* Node at term=5, voted_for=1, voted_for_term=5.
* Receives request from idx=1 at term=5 (same candidate, same term).
* Expected: idempotent re-grant → RAFT_GRANT, voted_for still 1.
*/
static void test_vote_req_same_term_idempotent_same_candidate(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1;
s.voted_for_term = 5;
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for still 1 */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_persist must fire once for the re-grant */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_leader_denies
*
* Node is RAFT_LEADER at term=5, receives request at term=5 (same term, no upgrade).
* Expected: RAFT_DENY ("vote_denied_role" trace), role stays LEADER.
*/
static void test_vote_req_leader_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.role = RAFT_LEADER;
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* Role gate fires: still LEADER (term==request term, no upgrade) */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
/* on_trace fires with "vote_denied_role" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_role", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_on_vote_eligible_denies
*
* on_vote_eligible returns 0 → RAFT_DENY, voted_for unchanged,
* "vote_denied_freshness" trace fires.
*/
static void test_vote_req_on_vote_eligible_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.vote_eligible_return_value = 0; /* deny */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be unchanged */
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* on_vote_eligible must have been called */
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
/* on_trace fires with "vote_denied_freshness" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_freshness", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_on_vote_eligible_grants
*
* on_vote_eligible returns 1 and persist succeeds → RAFT_GRANT, voted_for set.
*/
static void test_vote_req_on_vote_eligible_grants(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* grant */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be set */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_vote_eligible called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
/* on_persist called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
/* on_trace fires "vote_granted" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_granted", g_mock.last_trace_event);
}
/*
* test_vote_req_on_vote_eligible_null
*
* on_vote_eligible is NULL → treated as eligible, grant if persist ok.
*/
static void test_vote_req_on_vote_eligible_null(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
/* on_vote_eligible left NULL in make_mock_cbs */
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be set */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_persist called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_persist_fails
*
* on_persist returns non-zero → RAFT_DENY, voted_for unchanged.
*/
static void test_vote_req_persist_fails(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.persist_return_value = -1; /* fail */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must NOT be updated */
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
/* on_persist called once (it ran, but failed) */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_reply_term_always_set
*
* Verify that *reply_term == s->term on every return path by testing
* several scenarios and checking reply_term each time.
*/
static void test_vote_req_reply_term_always_set(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term;
/* Path 1: stale term deny */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 7));
reply_term = 0;
raft_on_vote_request(&s, 1, 3, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal current term=7 */
/* Path 2: term upgrade + grant */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
reply_term = 0;
raft_on_vote_request(&s, 1, 7, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal upgraded term=7 */
/* Path 3: already voted for different — deny */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1; s.voted_for_term = 5;
reply_term = 0;
raft_on_vote_request(&s, 2, 5, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
/* Path 4: persist fails — deny */
g_mock.persist_return_value = -1;
reset_mock();
g_mock.persist_return_value = -1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
reply_term = 0;
raft_on_vote_request(&s, 1, 5, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
}
/* ---------- raft_snapshot tests ---------- */
static void test_raft_snapshot_returns_current_state(void)
@ -607,4 +1024,16 @@ void test_raft(void)
RUN_TEST(test_start_election_single_node);
RUN_TEST(test_start_election_already_candidate);
RUN_TEST(test_start_election_already_leader);
/* raft_on_vote_request (task 3.3) */
RUN_TEST(test_vote_req_stale_term);
RUN_TEST(test_vote_req_higher_term_updates_state);
RUN_TEST(test_vote_req_same_term_already_voted_different);
RUN_TEST(test_vote_req_same_term_idempotent_same_candidate);
RUN_TEST(test_vote_req_leader_denies);
RUN_TEST(test_vote_req_on_vote_eligible_denies);
RUN_TEST(test_vote_req_on_vote_eligible_grants);
RUN_TEST(test_vote_req_on_vote_eligible_null);
RUN_TEST(test_vote_req_persist_fails);
RUN_TEST(test_vote_req_reply_term_always_set);
}