diff --git a/.kiro/specs/raft-library/tasks.md b/.kiro/specs/raft-library/tasks.md index 58e91727..58218903 100644 --- a/.kiro/specs/raft-library/tasks.md +++ b/.kiro/specs/raft-library/tasks.md @@ -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) diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index bfad07cd..70800317 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -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) diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index 6db2e8b7..ef414d5c 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -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);