feat(raft-library): task 3.5 — election timeout, leader-lost, check-and-start; 6 tests

This commit is contained in:
Rongsong Shen 2026-05-31 06:02:29 +00:00
parent f81054930f
commit 85a7e325a8
3 changed files with 262 additions and 6 deletions

View file

@ -59,7 +59,7 @@
- All-replied-no-quorum: if `replies_received == n_peers - 1` and `votes_granted < quorum`, set `role=RAFT_FOLLOWER`, `election_decided=1`, fire `on_become_follower(ctx, term, -1)`, `on_trace(became_follower)`, return `RAFT_BECAME_FOLLOWER`
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 3.5 Implement raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election
- [x] 3.5 Implement raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election
- `raft_on_election_timeout`: if `election_decided`, fire `on_cancel_timeout`, `on_trace(election_timeout_noop)`, return; otherwise set `role=RAFT_FOLLOWER`, `election_decided=1`, fire `on_become_follower(ctx, term, -1)`, `on_cancel_timeout`, `on_trace(election_timeout)`
- `raft_on_leader_lost`: clear `voted_for=-1`, set `role=RAFT_FOLLOWER`, fire `on_start_election_after_delay(ctx, my_priority * election_timeout_ms)`, fire `on_trace(leader_lost)`
- `raft_check_and_start_election`: if `voted_for != -1` AND `voted_for != self_idx` (another node won during the delay), fire `on_trace(jitter_skip)`, return `RAFT_SKIP_ELECTION`; otherwise call `raft_start_election(s)` internally, return `RAFT_ELECTION_STARTED`

View file

@ -224,16 +224,41 @@ int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term
void raft_on_election_timeout(raft_state_t *s)
{
(void)s;
if (s->election_decided) {
s->cbs->on_cancel_timeout(s->ctx);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "election_timeout_noop", NULL);
return;
}
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, "election_timeout", NULL);
}
void raft_on_leader_lost(raft_state_t *s)
{
(void)s;
s->voted_for = -1;
s->role = RAFT_FOLLOWER;
s->cbs->on_start_election_after_delay(s->ctx,
(uint32_t)s->my_priority * s->election_timeout_ms);
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "leader_lost", NULL);
}
int raft_check_and_start_election(raft_state_t *s)
{
(void)s;
return 0;
if (s->voted_for != -1 && s->voted_for != s->self_idx) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "jitter_skip", NULL);
return RAFT_SKIP_ELECTION;
}
/* Precondition: raft_on_leader_lost sets role=FOLLOWER, so -EALREADY is unreachable here */
int rc = raft_start_election(s);
(void)rc;
return RAFT_ELECTION_STARTED;
}

View file

@ -98,6 +98,10 @@ typedef struct {
/* on_cancel_timeout mock */
int cancel_timeout_count;
/* on_start_election_after_delay mock (task 3.5) */
int start_election_after_delay_count;
uint32_t last_start_election_delay;
} mock_cbs_state_t;
static mock_cbs_state_t g_mock;
@ -142,6 +146,13 @@ static void mock_cancel_timeout(void *ctx)
g_mock.cancel_timeout_count++;
}
static void mock_start_election_after_delay(void *ctx, uint32_t delay_ms)
{
(void)ctx;
g_mock.start_election_after_delay_count++;
g_mock.last_start_election_delay = delay_ms;
}
static int mock_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx;
@ -187,7 +198,7 @@ static raft_callbacks_t make_mock_cbs(void)
cbs.on_persist = mock_persist;
cbs.on_schedule_timeout = mock_schedule_timeout;
cbs.on_cancel_timeout = mock_cancel_timeout;
cbs.on_start_election_after_delay = stub_start_election_after_delay;
cbs.on_start_election_after_delay = mock_start_election_after_delay;
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
return cbs;
}
@ -1225,6 +1236,218 @@ static void test_vote_reply_pending_waiting(void)
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/* ---------- raft_on_election_timeout tests (task 3.5) ---------- */
/*
* test_election_timeout_undecided
*
* Node is CANDIDATE, election_decided=0.
* raft_on_election_timeout must:
* - set role=FOLLOWER
* - set election_decided=1
* - fire on_become_follower once
* - fire on_cancel_timeout once
* - trace "election_timeout"
*/
static void test_election_timeout_undecided(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.election_decided = 0;
int become_follower_before = g_mock.become_follower_count;
raft_on_election_timeout(&s);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower fired once (with current term, leader_idx=-1) */
TEST_ASSERT_EQUAL_INT(become_follower_before + 1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(2, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_cancel_timeout fired once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* trace fired with "election_timeout" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("election_timeout", g_mock.last_trace_event);
}
/*
* test_election_timeout_already_decided
*
* election_decided=1 already set (election outcome already fired).
* raft_on_election_timeout must:
* - fire on_cancel_timeout once (stale timer cleanup)
* - NOT fire an additional on_become_follower
* - trace "election_timeout_noop"
*/
static void test_election_timeout_already_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.election_decided = 1; /* already decided */
/* Snapshot become_follower_count before the call */
int become_follower_before = g_mock.become_follower_count;
raft_on_election_timeout(&s);
/* on_cancel_timeout must fire once (stale timer cleanup) */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_follower must NOT have fired an additional time */
TEST_ASSERT_EQUAL_INT(become_follower_before, g_mock.become_follower_count);
/* trace fired with "election_timeout_noop" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("election_timeout_noop", g_mock.last_trace_event);
}
/* ---------- raft_on_leader_lost tests (task 3.5) ---------- */
/*
* test_leader_lost_clears_voted_for
*
* Node had voted_for=1. raft_on_leader_lost must:
* - set voted_for=-1
* - set role=FOLLOWER
* - fire on_start_election_after_delay with delay = my_priority * election_timeout_ms
* - trace "leader_lost"
*/
static void test_leader_lost_clears_voted_for(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 500;
cfg.my_priority = 2;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
s.voted_for = 1;
s.role = RAFT_FOLLOWER;
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* on_start_election_after_delay fired once with delay = priority * timeout */
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
TEST_ASSERT_EQUAL_UINT32(2 * 500, g_mock.last_start_election_delay);
/* trace fired with "leader_lost" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("leader_lost", g_mock.last_trace_event);
}
/* ---------- raft_check_and_start_election tests (task 3.5) ---------- */
/*
* test_check_and_start_voted_for_other
*
* voted_for=1, self_idx=0 (voted for a different candidate).
* Must return RAFT_SKIP_ELECTION, role stays FOLLOWER, no election started.
* Trace "jitter_skip" fires.
*/
static void test_check_and_start_voted_for_other(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.voted_for = 1; /* voted for peer 1, not self (0) */
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* No election RPCs must have fired */
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
/* trace fired with "jitter_skip" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event);
}
/*
* test_check_and_start_not_voted
*
* voted_for==-1 (no vote cast yet).
* Must call raft_start_election internally and return RAFT_ELECTION_STARTED.
* role becomes CANDIDATE (3-node cluster).
*/
static void test_check_and_start_not_voted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = -1;
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
/* raft_start_election must have fired vote requests */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.send_vote_request_count);
}
/*
* test_check_and_start_voted_for_self
*
* voted_for==self_idx (we previously voted for ourselves — this means
* we already started an election, so start again to re-enter candidacy).
* Must return RAFT_ELECTION_STARTED, not RAFT_SKIP_ELECTION.
*/
static void test_check_and_start_voted_for_self(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = 0; /* voted for self (self_idx=0) */
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
}
/* ---------- raft_snapshot tests ---------- */
static void test_raft_snapshot_returns_current_state(void)
@ -1305,4 +1528,12 @@ void test_raft(void)
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);
/* raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election (task 3.5) */
RUN_TEST(test_election_timeout_undecided);
RUN_TEST(test_election_timeout_already_decided);
RUN_TEST(test_leader_lost_clears_voted_for);
RUN_TEST(test_check_and_start_voted_for_other);
RUN_TEST(test_check_and_start_not_voted);
RUN_TEST(test_check_and_start_voted_for_self);
}