diff --git a/.kiro/specs/raft-library/tasks.md b/.kiro/specs/raft-library/tasks.md index 09bfbc22..102c643d 100644 --- a/.kiro/specs/raft-library/tasks.md +++ b/.kiro/specs/raft-library/tasks.md @@ -117,7 +117,7 @@ - All 11 Unity test cases pass - _Requirements: 1.2, 3.4, 3.5, 7.2, 7.3, 8.3_ -- [ ] 5.3 Test Groups D and E: quorum evaluation and election timeout (7 cases) +- [x] 5.3 Test Groups D and E: quorum evaluation and election timeout (7 cases) - D1: 3-node cluster, 1 peer grant received (self + 1 = quorum 2) → `role==RAFT_LEADER`; `on_become_leader` fired once - D2: 3-node, both peers deny → `election_decided==1`, `role==RAFT_FOLLOWER`; `on_become_follower` fired - D3: `on_become_leader` fired; second vote reply arrives → returns `RAFT_PENDING`; `leader_count` stays 1 diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index e224d77c..9b8d69b7 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -225,6 +225,7 @@ 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) { if (s->election_decided) { + /* Req 6.3: cancel any pending timer and be a no-op */ s->cbs->on_cancel_timeout(s->ctx); if (s->cbs->on_trace) s->cbs->on_trace(s->ctx, "election_timeout_noop", NULL); diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index 1e26117a..dcfea37d 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -1287,7 +1287,7 @@ static void test_election_timeout_undecided(void) * * election_decided=1 already set (election outcome already fired). * raft_on_election_timeout must: - * - fire on_cancel_timeout once (stale timer cleanup) + * - fire on_cancel_timeout once (stale timer cleanup, Req 6.3) * - NOT fire an additional on_become_follower * - trace "election_timeout_noop" */ @@ -1309,7 +1309,7 @@ static void test_election_timeout_already_decided(void) raft_on_election_timeout(&s); - /* on_cancel_timeout must fire once (stale timer cleanup) */ + /* on_cancel_timeout must fire once (stale timer cleanup, Req 6.3) */ TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count); /* on_become_follower must NOT have fired an additional time */ @@ -2320,6 +2320,270 @@ static void test_proto_B5_idempotent_same_candidate(void) TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count); } +/* ---------- Group D: Quorum and Result (task 5.3) ---------- */ + +/* + * test_proto_D1_quorum_1grant_of_2_needed + * + * 3-node cluster, node 0 starts election (votes_granted=1, n_peers=3, quorum=2). + * Feed 1 grant reply from peer 1: votes_granted becomes 2 == quorum. + * Expected: RAFT_BECAME_LEADER, role==RAFT_LEADER, election_decided==1, + * on_become_leader fired exactly once. + */ +static void test_proto_D1_quorum_1grant_of_2_needed(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)); + + /* Start election: votes_granted=1 (self), role=CANDIDATE */ + int start_rc = raft_start_election(&s); + TEST_ASSERT_EQUAL_INT(RAFT_OK, start_rc); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + TEST_ASSERT_EQUAL_INT(1, s.votes_granted); + TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count); + + /* Feed 1 grant from peer 1: votes_granted=2 == quorum=2 */ + 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); + + /* on_become_leader must have fired exactly once */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); +} + +/* + * test_proto_D2_both_peers_deny_becomes_follower + * + * 3-node cluster, node 0 starts election (votes_granted=1, replies_received=0). + * Feed 2 deny replies from peers 1 and 2. + * After second deny: replies_received==2 == n_peers-1=2, votes_granted=1 < quorum=2. + * Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, election_decided==1, + * on_become_follower fired. + */ +static void test_proto_D2_both_peers_deny_becomes_follower(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)); + + /* Start election */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + TEST_ASSERT_EQUAL_INT(0, s.replies_received); + + /* First deny from peer 1: replies_received=1, still pending */ + 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, s.election_decided); + + /* Second deny from peer 2: 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 have fired once */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count); +} + +/* + * test_proto_D3_second_vote_reply_noop + * + * 3-node cluster, start election. + * Feed 1 grant → RAFT_BECAME_LEADER (election_decided=1). + * Feed a second grant from peer 2 → RAFT_PENDING (noop, election_decided guard). + * Expected: become_leader_count still 1 (on_become_leader fired exactly once). + */ +static void test_proto_D3_second_vote_reply_noop(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)); + + /* Start election */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + + /* First grant: wins election */ + int rc1 = raft_on_vote_reply(&s, 1, 1, 1); + TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc1); + TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role); + TEST_ASSERT_EQUAL_INT(1, s.election_decided); + TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); + + /* Second grant from peer 2: election already decided → noop */ + int rc2 = raft_on_vote_reply(&s, 2, 1, 1); + TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc2); + + /* on_become_leader must NOT have fired again */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); +} + +/* + * test_proto_D4_reply_higher_term_steps_down + * + * 3-node cluster, start election (term=1). + * Feed reply with term=3 (granted=0) → higher term → step down. + * Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, s.term==3, + * election_decided==1, on_become_follower fired. + */ +static void test_proto_D4_reply_higher_term_steps_down(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)); + + /* Start election: term becomes 1 */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(1, (int)s.term); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + + /* Reply with higher term=3, not granted */ + int rc = raft_on_vote_reply(&s, 1, 0, 3); + + TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + TEST_ASSERT_EQUAL_UINT64(3, s.term); + TEST_ASSERT_EQUAL_INT(1, s.election_decided); + + /* on_become_follower must have fired once */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count); +} + +/* ---------- Group E: Election Timeout (task 5.3) ---------- */ + +/* + * test_proto_E1_timeout_before_quorum + * + * 3-node cluster, start election (on_schedule_timeout fires → schedule_timeout_count==1). + * Call raft_on_election_timeout before any replies. + * Expected: role==RAFT_FOLLOWER, election_decided==1, on_become_follower fired, + * on_cancel_timeout fired. + */ +static void test_proto_E1_timeout_before_quorum(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)); + + /* Start election: schedule_timeout fires */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count); + + /* Fire timeout before any replies */ + raft_on_election_timeout(&s); + + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + TEST_ASSERT_EQUAL_INT(1, s.election_decided); + + /* on_become_follower must have fired */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count); + + /* on_cancel_timeout must have fired */ + TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count); +} + +/* + * test_proto_E2_timeout_after_quorum_noop + * + * 3-node cluster, start election, feed 1 grant → RAFT_BECAME_LEADER. + * Call raft_on_election_timeout. + * Expected: role still RAFT_LEADER (already decided, timeout is noop), + * on_cancel_timeout fired (stale timer cleanup), + * on_become_follower NOT additionally fired. + */ +static void test_proto_E2_timeout_after_quorum_noop(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)); + + /* Start election */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + + /* Feed 1 grant → LEADER */ + 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); + + /* Snapshot counters before stale timeout delivery */ + int follower_before = g_mock.become_follower_count; + int cancel_before = g_mock.cancel_timeout_count; + + /* Fire timeout: already decided → fires on_cancel_timeout, no other effects (Req 6.3) */ + raft_on_election_timeout(&s); + + /* Role must still be LEADER */ + TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role); + + /* on_cancel_timeout fires exactly once for the stale delivery (Req 6.3) */ + TEST_ASSERT_EQUAL_INT(cancel_before + 1, g_mock.cancel_timeout_count); + + /* on_become_follower must NOT have fired additionally */ + TEST_ASSERT_EQUAL_INT(follower_before, g_mock.become_follower_count); +} + +/* + * test_proto_E3_new_election_after_timeout + * + * 3-node cluster, start election (term=1). + * Timeout fires → becomes FOLLOWER (election_decided=1). + * Start new election: raft_start_election again. + * Expected: term==2 (incremented), on_schedule_timeout fired again + * (schedule_timeout_count==2 total). + */ +static void test_proto_E3_new_election_after_timeout(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)); + + /* First election: term=1 */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_UINT64(1, s.term); + TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count); + + /* Timeout: step down to FOLLOWER */ + raft_on_election_timeout(&s); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + TEST_ASSERT_EQUAL_INT(1, s.election_decided); + + /* Start new election: election_decided=1 means old state decided — need to + * reset election state so raft_start_election allows another attempt. + * Per spec: a new election restarts the state machine; reset election_decided. */ + s.election_decided = 0; + s.role = RAFT_FOLLOWER; + + int rc2 = raft_start_election(&s); + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc2); + + /* Term must have incremented to 2 */ + TEST_ASSERT_EQUAL_UINT64(2, s.term); + + /* on_schedule_timeout must have fired again (total 2) */ + TEST_ASSERT_EQUAL_INT(2, g_mock.schedule_timeout_count); +} + #endif /* XCE_TESTING */ /* ---------- test_raft entry point ---------- */ @@ -2414,5 +2678,16 @@ void test_raft(void) RUN_TEST(test_proto_H4_persist_fail_denies); RUN_TEST(test_proto_H5_start_election_self_vote_persisted); RUN_TEST(test_proto_Hnull_required_callback_einval); + + /* Group D: Quorum and Result (task 5.3) */ + RUN_TEST(test_proto_D1_quorum_1grant_of_2_needed); + RUN_TEST(test_proto_D2_both_peers_deny_becomes_follower); + RUN_TEST(test_proto_D3_second_vote_reply_noop); + RUN_TEST(test_proto_D4_reply_higher_term_steps_down); + + /* Group E: Election Timeout (task 5.3) */ + RUN_TEST(test_proto_E1_timeout_before_quorum); + RUN_TEST(test_proto_E2_timeout_after_quorum_noop); + RUN_TEST(test_proto_E3_new_election_after_timeout); #endif }