diff --git a/.kiro/specs/raft-library/tasks.md b/.kiro/specs/raft-library/tasks.md index c4b2eb10..083dd57f 100644 --- a/.kiro/specs/raft-library/tasks.md +++ b/.kiro/specs/raft-library/tasks.md @@ -31,7 +31,7 @@ - `raft_init` with a NULL required callback returns `-EINVAL`; subsequent `raft_start_election` is not callable - _Requirements: 1.2, 7.2, 7.3_ -- [ ] 3.2 Implement raft_start_election +- [x] 3.2 Implement raft_start_election - Guard: returns `-EALREADY` if `role` is `RAFT_CANDIDATE` or `RAFT_LEADER` without modifying state - Increments `term`, sets `role=RAFT_CANDIDATE`, records `voted_for=self_idx`, `voted_for_term=term`, resets `votes_granted=1`, `replies_received=0`, `election_decided=0` - Fires `on_persist(ctx, new_term, self_idx)` for self-vote durability before sending any RPC diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index 5ddadde0..8cab653b 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -51,8 +51,51 @@ int raft_init(raft_state_t *s, const raft_config_t *cfg) int raft_start_election(raft_state_t *s) { - (void)s; - return 0; + /* Guard: reject if already CANDIDATE or LEADER */ + if (s->role == RAFT_CANDIDATE || s->role == RAFT_LEADER) + return -EALREADY; + + /* Increment term and record self-vote before any RPCs (split-brain prevention) */ + s->term++; + s->voted_for = s->self_idx; + s->voted_for_term = s->term; + s->votes_granted = 1; /* self-vote */ + s->replies_received = 0; + s->election_decided = 0; + + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "term_update", NULL); + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "self_vote", NULL); + + /* Persist new term and self-vote before sending any RPCs */ + s->cbs->on_persist(s->ctx, s->term, s->self_idx); + + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "election_start", NULL); + + /* Single-node shortcircuit: win immediately, no RPCs, no timeout */ + if (s->n_peers == 1) { + s->role = RAFT_LEADER; + s->election_decided = 1; + s->cbs->on_become_leader(s->ctx, s->term); + return RAFT_ELECTED_IMMEDIATELY; + } + + /* Multi-node: transition to CANDIDATE */ + s->role = RAFT_CANDIDATE; + + /* Send RequestVote to all peers except self */ + for (int i = 0; i < s->n_peers; i++) { + if (i == s->self_idx) + continue; + s->cbs->on_send_vote_request(s->ctx, i, s->term); + } + + /* Schedule election timeout watchdog */ + s->cbs->on_schedule_timeout(s->ctx, s->election_timeout_ms); + + return RAFT_OK; } int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term, diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index b4e3c1fa..f32b7dab 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -62,6 +62,98 @@ static raft_callbacks_t make_full_cbs(void) return cbs; } +/* ---------- mock infrastructure for raft_start_election tests ---------- */ + +/* Per-test counters — reset by each test before calling start_election */ +typedef struct { + 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 schedule_timeout_count; + uint32_t last_schedule_delay; + + int persist_count; + uint64_t last_persist_term; + int last_persist_voted_for; + + int become_leader_count; + uint64_t last_become_leader_term; +} mock_cbs_state_t; + +static mock_cbs_state_t g_mock; + +static void reset_mock(void) +{ + memset(&g_mock, 0, sizeof(g_mock)); +} + +static int mock_send_vote_request(void *ctx, int peer_idx, uint64_t term) +{ + (void)ctx; + if (g_mock.send_vote_request_count < 8) + g_mock.sent_peers[g_mock.send_vote_request_count] = peer_idx; + g_mock.last_send_peer_idx = peer_idx; + g_mock.last_send_term = term; + g_mock.send_vote_request_count++; + return 0; +} + +static void mock_become_leader(void *ctx, uint64_t term) +{ + (void)ctx; + g_mock.become_leader_count++; + g_mock.last_become_leader_term = term; +} + +static int mock_persist(void *ctx, uint64_t term, int voted_for) +{ + (void)ctx; + g_mock.persist_count++; + g_mock.last_persist_term = term; + g_mock.last_persist_voted_for = voted_for; + return 0; +} + +static void mock_schedule_timeout(void *ctx, uint32_t delay_ms) +{ + (void)ctx; + g_mock.schedule_timeout_count++; + g_mock.last_schedule_delay = delay_ms; +} + +/* Build callbacks that record invocations in g_mock */ +static raft_callbacks_t make_mock_cbs(void) +{ + raft_callbacks_t cbs; + 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_persist = mock_persist; + 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 */ + return cbs; +} + +/* ---------- helpers ---------- */ + +/* Initialize a raft_state_t with mock callbacks; n_peers and self_idx configurable */ +static int init_node(raft_state_t *s, raft_callbacks_t *cbs, int n_peers, int self_idx) +{ + 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.cbs = cbs; + return raft_init(s, &cfg); +} + /* ---------- raft_init tests ---------- */ /* raft_init with NULL required callback returns -EINVAL and does not modify s */ @@ -281,6 +373,180 @@ static void test_raft_init_optional_callbacks_null_succeeds(void) TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); } +/* ---------- raft_start_election tests (task 3.2) ---------- */ + +/* + * test_start_election_from_follower_3node + * + * 3-node cluster (self=0). raft_start_election transitions to CANDIDATE, + * increments term, records self-vote, fires on_persist(new_term, self_idx), + * fires on_send_vote_request for each peer != self_idx (peers 1 and 2), + * fires on_schedule_timeout once, returns RAFT_OK. + */ +static void test_start_election_from_follower_3node(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)); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + TEST_ASSERT_EQUAL_UINT64(0, s.term); + + int rc = raft_start_election(&s); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + TEST_ASSERT_EQUAL_UINT64(1, s.term); + TEST_ASSERT_EQUAL_INT(0, s.self_idx); + TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* voted_for == self_idx */ + TEST_ASSERT_EQUAL_UINT64(1, s.voted_for_term); /* voted_for_term == new term */ + TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* self-vote counted */ + TEST_ASSERT_EQUAL_INT(0, s.replies_received); + TEST_ASSERT_EQUAL_INT(0, s.election_decided); + + /* on_persist must fire once with (new_term=1, self_idx=0) */ + TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count); + TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term); + TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for); + + /* on_send_vote_request must fire for peers 1 and 2 (not self=0) */ + TEST_ASSERT_EQUAL_INT(2, g_mock.send_vote_request_count); + /* Both sends must use the new term */ + TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_send_term); + /* Verify neither sent peer is self_idx=0 */ + { + int i; + for (i = 0; i < g_mock.send_vote_request_count; i++) { + TEST_ASSERT_NOT_EQUAL(0, g_mock.sent_peers[i]); + } + } + + /* on_schedule_timeout must fire exactly once */ + TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count); + TEST_ASSERT_EQUAL_UINT32(1000, g_mock.last_schedule_delay); + + /* on_become_leader must NOT fire (multi-node path) */ + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); +} + +/* + * test_start_election_single_node + * + * 1-node cluster (self=0). raft_start_election shortcircuits to LEADER, + * fires on_persist then on_become_leader, returns RAFT_ELECTED_IMMEDIATELY. + * No on_send_vote_request or on_schedule_timeout calls. + */ +static void test_start_election_single_node(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 1, 0)); + + int rc = raft_start_election(&s); + + TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc); + TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role); + TEST_ASSERT_EQUAL_UINT64(1, s.term); + TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* self-vote */ + TEST_ASSERT_EQUAL_INT(1, s.election_decided); + + /* on_persist fires once with (term=1, self_idx=0) */ + TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count); + TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term); + TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for); + + /* on_become_leader fires once with term=1 */ + TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); + TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term); + + /* on_send_vote_request must NOT fire */ + TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count); + + /* on_schedule_timeout must NOT fire */ + TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count); +} + +/* + * test_start_election_already_candidate + * + * Call raft_start_election once (become CANDIDATE), then call again. + * Second call must return -EALREADY and leave state completely unchanged. + */ +static void test_start_election_already_candidate(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 1)); + + /* First election — becomes CANDIDATE */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + uint64_t term_after_first = s.term; + + /* Snapshot the state before the second call */ + int saved_voted_for = s.voted_for; + int saved_votes_granted = s.votes_granted; + int saved_replies_recvd = s.replies_received; + int saved_election_dec = s.election_decided; + + reset_mock(); + + /* Second call must be rejected */ + int rc = raft_start_election(&s); + TEST_ASSERT_EQUAL_INT(-EALREADY, rc); + + /* State must be unchanged */ + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + TEST_ASSERT_EQUAL_UINT64(term_after_first, s.term); + TEST_ASSERT_EQUAL_INT(saved_voted_for, s.voted_for); + TEST_ASSERT_EQUAL_INT(saved_votes_granted, s.votes_granted); + TEST_ASSERT_EQUAL_INT(saved_replies_recvd, s.replies_received); + TEST_ASSERT_EQUAL_INT(saved_election_dec, s.election_decided); + + /* No callbacks must have fired on the second call */ + TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); +} + +/* + * test_start_election_already_leader + * + * Manually set role=RAFT_LEADER, then call raft_start_election. + * Must return -EALREADY without modifying any state or firing any callback. + */ +static void test_start_election_already_leader(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_mock_cbs(); + + reset_mock(); + TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 2)); + + /* Manually elevate to LEADER */ + s.role = RAFT_LEADER; + s.term = 5; + + int rc = raft_start_election(&s); + TEST_ASSERT_EQUAL_INT(-EALREADY, rc); + + /* All state must be unchanged */ + TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role); + TEST_ASSERT_EQUAL_UINT64(5, s.term); + + /* No callbacks must fire */ + TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count); + TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count); +} + /* ---------- raft_snapshot tests ---------- */ static void test_raft_snapshot_returns_current_state(void) @@ -335,4 +601,10 @@ void test_raft(void) RUN_TEST(test_raft_init_default_election_timeout); RUN_TEST(test_raft_init_optional_callbacks_null_succeeds); RUN_TEST(test_raft_snapshot_returns_current_state); + + /* raft_start_election (task 3.2) */ + RUN_TEST(test_start_election_from_follower_3node); + RUN_TEST(test_start_election_single_node); + RUN_TEST(test_start_election_already_candidate); + RUN_TEST(test_start_election_already_leader); }