From 3c5185a5c335d4f1b3587e0f46b3618d59499b4f Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sun, 21 Jun 2026 16:18:02 +0000 Subject: [PATCH] feat(raft-log-replication): raft_on_append_entries_request UpdateTerm+logOk+accept (tasks 5.1-5.2, 6.1-6.3) Complete HandleAppendEntriesRequest (raft.tla:327) + UpdateTerm (raft.tla:406), implemented as one function to avoid a misleading partial handler. Fixed step order: UpdateTerm FIRST on every RPC incl. heartbeat (G5, strictly-greater, clears voted_for, persists, leader step-down fires on_become_follower) -> stale reject -> candidate step-down -> logOk (G2) -> reject if !logOk -> accept (already-have / conflict-truncate-then-repersist (G3) / append). Never advances commit, never does transport I/O. Review-applied: logOk now includes the explicit prev_log_index <= log_len bound (raft.tla:330 / Req 4.1) instead of relying solely on the 0-absent sentinel; added a candidate-equal-term-logOk-fails step-ordering test. 15 tests, ASAN green. --- .kiro/specs/raft-log-replication/tasks.md | 10 +- src/lib/raft/raft.c | 136 +++++ src/lib/testsuite/test_raft.c | 615 ++++++++++++++++++++++ 3 files changed, 756 insertions(+), 5 deletions(-) diff --git a/.kiro/specs/raft-log-replication/tasks.md b/.kiro/specs/raft-log-replication/tasks.md index a9df95c2..3849b06d 100644 --- a/.kiro/specs/raft-log-replication/tasks.md +++ b/.kiro/specs/raft-log-replication/tasks.md @@ -81,14 +81,14 @@ ## 5. UpdateTerm-on-AppendEntries (raft.tla:406, closes G5) -- [ ] 5.1 RED: higher-term AE/heartbeat adopts term before logOk +- [x] 5.1 RED: higher-term AE/heartbeat adopts term before logOk - Test: a follower and a leader at term T receiving an AE (and separately a 0-entry heartbeat) at term T+1 adopt T+1, clear `voted_for`, call `on_persist(T+1,-1)`, and (was-leader case) fire `on_become_follower`; assert this happens even when the local log would fail `logOk` - Observable completion: tests compile and FAIL - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ - _Boundary: test_raft.c_ - _Depends: 1.3_ -- [ ] 5.2 GREEN: term-adoption block at the top of the request handler +- [x] 5.2 GREEN: term-adoption block at the top of the request handler - Implement the `UpdateTerm` block as the FIRST step of `raft_on_append_entries_request`, before `logOk`; persist-before-process; fire `on_become_follower` if was leader; no change on equal/lower term - Observable completion: 5.1 tests pass; the block is unmistakably before `logOk` in `raft.c` - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_ @@ -97,21 +97,21 @@ ## 6. HandleAppendEntriesRequest — logOk / reject / step-down / accept (raft.tla:327, closes G2/G3) -- [ ] 6.1 RED: logOk + reject + candidate step-down tests +- [x] 6.1 RED: logOk + reject + candidate step-down tests - Test: `logOk` true when `prevLogIndex==0` or `on_get_entry_term(prevLogIndex)==prevLogTerm`; reject (success=0, matchIndex=0) on stale term and on `follower && !logOk`; candidate at equal term transitions to follower - Observable completion: tests compile and FAIL - _Requirements: 4.1, 4.2, 4.3_ - _Boundary: test_raft.c_ - _Depends: 5.2_ -- [ ] 6.2 RED: accept branch tests (already-have / conflict-truncate / append) +- [x] 6.2 RED: accept branch tests (already-have / conflict-truncate / append) - Test: already-have (empty entries or matching term) → success, no log rewrite, `matchIndex = prevLogIndex + entries_len`; conflict (`local[index].term != entry.term`) → `on_truncate_from(index)` then `on_persist_entry(index, entry)`, success `matchIndex=index`; append (`log_len == prevLogIndex`) → `on_persist_entry(index)`, success - Observable completion: tests compile and FAIL - _Requirements: 4.4, 4.5, 4.6, 4.7_ - _Boundary: test_raft.c_ - _Depends: 6.1_ -- [ ] 6.3 GREEN: implement `raft_on_append_entries_request` (logOk + all branches) +- [x] 6.3 GREEN: implement `raft_on_append_entries_request` (logOk + all branches) - Implement (after the 5.2 UpdateTerm block): `logOk` via `on_get_entry_term`; reject/candidate-step-down/accept; the three accept sub-cases exactly per raft.tla:351-388; fill `raft_append_result_t`; never advance commit (`leader_commit` ignored); never perform transport I/O - Observable completion: 6.1 and 6.2 tests pass; `on_truncate_from` is always followed by re-persist of the leader entry on the conflict branch - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_ diff --git a/src/lib/raft/raft.c b/src/lib/raft/raft.c index bc2f5f86..0b666f64 100644 --- a/src/lib/raft/raft.c +++ b/src/lib/raft/raft.c @@ -541,3 +541,139 @@ int raft_send_append_entries(raft_state_t *s, int peer_idx, return RAFT_OK; } + +/* Fill *out as a rejection (success=0, match_index=0) at the current term and + * return RAFT_OK. Every reject branch shares this shape (raft.tla reject, 333). */ +static int raft_ae_reject(raft_state_t *s, raft_append_result_t *out, + const char *trace_event) +{ + out->reply_term = s->term; + out->success = 0; + out->match_index = 0; + if (trace_event && s->cbs->on_trace) + s->cbs->on_trace(s->ctx, trace_event, NULL); + return RAFT_OK; +} + +int raft_on_append_entries_request(raft_state_t *s, int leader_idx, + uint64_t term, uint64_t prev_log_index, + uint64_t prev_log_term, uint64_t leader_commit, + const void *entry, size_t entry_len, + uint64_t entry_term, int entries_len, + raft_append_result_t *out) +{ + /* leader_commit is accepted into the contract but NOT acted on in this spec + * — commit-index advance is owned by raft-commit-safety (Req 3.4 / 5.4). */ + (void)leader_commit; + + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "ae_recv", NULL); + + /* ---- Step 1: UpdateTerm FIRST (raft.tla 406, Req 6.1–6.5 — closes G5) ---- + * Applied on EVERY incoming AppendEntries/heartbeat, BEFORE logOk, so a + * strictly-higher-term RPC is never spuriously rejected for a log mismatch. + * Strictly-greater-than only: equal/lower term never alters term/voted_for. */ + if (term > s->term) { + int was_leader = (s->role == RAFT_LEADER); + s->term = term; + s->voted_for = -1; + s->voted_for_term = 0; + s->role = RAFT_FOLLOWER; + /* Persist the adopted (term, voted_for) before processing the rest of + * the RPC (durability ordering, Req 6.2). */ + s->cbs->on_persist(s->ctx, s->term, -1); + /* A node that was leader must notify the HA FSM so it steps down + * (Req 6.3), mirroring the vote-path higher-term step-down. */ + if (was_leader) + s->cbs->on_become_follower(s->ctx, s->term, leader_idx); + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "term_adopt_ae", NULL); + } + + /* ---- Step 2: stale-term reject (raft.tla 333) ---- + * After UpdateTerm, term < s->term can only happen for a strictly-lower + * incoming term (the higher case adopted above). */ + if (term < s->term) + return raft_ae_reject(s, out, "ae_reject_stale"); + + /* ---- Step 3: candidate step-down at equal term (raft.tla 346) ---- + * A candidate that sees an AppendEntries at its own term concedes that a + * leader already exists for this term and returns to follower. */ + if (s->role == RAFT_CANDIDATE) + s->role = RAFT_FOLLOWER; + + /* ---- Step 4: logOk (raft.tla 328, Req 4.1 — closes G2) ---- + * logOk = prevLogIndex == 0 + * OR (prevLogIndex <= log_len AND on_get_entry_term(prevLogIndex) == prevLogTerm). + * The explicit prevLogIndex <= log_len bound mirrors raft.tla:330 + * (prevLogIndex <= Len(log)). The on_get_entry_term 0 sentinel already + * rejects absent entries for any non-zero prevLogTerm, but the bound also + * defends against a malformed sender presenting prevLogTerm==0 with + * prevLogIndex past the local log (0==0 would otherwise spuriously pass). */ + int log_ok = (prev_log_index == 0) || + (prev_log_index <= s->log_len && + s->cbs->on_get_entry_term(s->ctx, prev_log_index) == prev_log_term); + + /* ---- Step 5: reject if !logOk (follower path, raft.tla 333, Req 4.2) ---- */ + if (!log_ok) + return raft_ae_reject(s, out, "ae_reject_logmismatch"); + + /* ---- Step 6: accept (raft.tla 351), index = prevLogIndex + 1 ---- + * match_index reported on success is prevLogIndex + entries_len for every + * accept sub-case (already-have / conflict-truncate / append). */ + uint64_t index = prev_log_index + 1; + uint64_t match_index = prev_log_index + (uint64_t)entries_len; + + /* already-have: a heartbeat (no entries), or the local entry at `index` + * already carries the incoming entry's term → success, no log rewrite. */ + if (entries_len == 0 || + s->cbs->on_get_entry_term(s->ctx, index) == entry_term) { + out->reply_term = s->term; + out->success = 1; + out->match_index = match_index; + /* must not shrink the log (already-have keeps the existing suffix) */ + if (s->log_len < match_index) + s->log_len = match_index; + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "ae_already_have", NULL); + return RAFT_OK; + } + + /* conflict-truncate (raft.tla 375, Req 4.5 — closes G3): a local entry at + * `index` exists with a DIFFERENT term. Delete the divergent suffix, then + * (intentional safe collapse) re-persist the leader's entry in the same + * handler invocation. The truncate is ALWAYS followed by the re-persist. */ + if (s->cbs->on_get_entry_term(s->ctx, index) != 0) { + int rc = s->cbs->on_truncate_from(s->ctx, index); + if (rc != 0) + /* truncate failed → report a non-success so the leader retries; the + * follower's log is not corrupted (it simply did not converge). */ + return raft_ae_reject(s, out, "ae_conflict_truncate_fail"); + rc = s->cbs->on_persist_entry(s->ctx, index, entry_term, entry, entry_len); + if (rc != 0) + return raft_ae_reject(s, out, "ae_conflict_persist_fail"); + out->reply_term = s->term; + out->success = 1; + out->match_index = match_index; + s->log_len = match_index; + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "ae_conflict_truncate", NULL); + return RAFT_OK; + } + + /* append (raft.tla 383, Req 4.6): the local log ends exactly at + * prevLogIndex (no entry at `index`) → append the incoming entry. */ + { + int rc = s->cbs->on_persist_entry(s->ctx, index, entry_term, entry, entry_len); + if (rc != 0) + return raft_ae_reject(s, out, "ae_append_persist_fail"); + out->reply_term = s->term; + out->success = 1; + out->match_index = match_index; + if (s->log_len < match_index) + s->log_len = match_index; + if (s->cbs->on_trace) + s->cbs->on_trace(s->ctx, "ae_append", NULL); + return RAFT_OK; + } +} diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index e6fa539c..4ebd6a0c 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -1891,6 +1891,603 @@ static void test_send_ae_non_leader_emits_nothing(void) TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); } +/* ---------- raft_on_append_entries_request tests + * (tasks 5.1+5.2 UpdateTerm-first / 6.1 logOk+reject+candidate-step-down / + * 6.2+6.3 accept already-have/conflict-truncate/append; + * raft.tla HandleAppendEntriesRequest 327 + UpdateTerm 406) ---------- + * + * Reuses the existing spy_persist_entry/g_pe_spy, spy_get_entry_term/g_gt_spy, + * and mock_persist/mock_become_follower (g_mock). Adds a truncate spy. + */ + +/* Spy on_truncate_from: records each call's index and a call count. */ +typedef struct { + int call_count; + uint64_t last_index; +} truncate_spy_t; + +static truncate_spy_t g_tr_spy; + +static void reset_tr_spy(void) +{ + memset(&g_tr_spy, 0, sizeof(g_tr_spy)); +} + +static int spy_truncate_from(void *ctx, uint64_t index) +{ + (void)ctx; + g_tr_spy.call_count++; + g_tr_spy.last_index = index; + return 0; +} + +/* Build callbacks wired to ALL the log-replication spies + g_mock for the + * shared election-side callbacks (on_persist, on_become_follower). This lets + * the AppendEntries-request tests observe term adoption (mock_persist / + * mock_become_follower) and log mutation (pe/gt/tr spies) together. */ +static raft_callbacks_t make_ae_req_cbs(void) +{ + raft_callbacks_t cbs = make_mock_cbs(); + cbs.on_persist_entry = spy_persist_entry; + cbs.on_get_entry_term = spy_get_entry_term; + cbs.on_truncate_from = spy_truncate_from; + return cbs; +} + +static void reset_ae_req_spies(void) +{ + reset_mock(); + reset_pe_spy(); + reset_gt_spy(); + reset_tr_spy(); +} + +/* + * test_ae_req_follower_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.2, 6.5 — G5) + * + * A FOLLOWER at term T receiving an AppendEntries at term T+1 must adopt T+1, + * clear voted_for (=-1), and persist (T+1, -1) via on_persist BEFORE the rest of + * the RPC — even when the local log would FAIL logOk (prev_log_index/term that + * mismatch). Term adoption happens regardless of logOk. reply_term carries the + * adopted term. (on_become_follower NOT required for a non-leader adopter.) + */ +static void test_ae_req_follower_adopts_higher_term(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + /* restored log_len = 2; node starts FOLLOWER at term 5, voted_for=1 */ + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2)); + s.term = 5; + s.role = RAFT_FOLLOWER; + s.voted_for = 1; + s.voted_for_term = 5; + /* logOk WILL fail: prev_log_index=2 expects term 9, but local entry 2 is term 5 */ + g_gt_spy.term_at[2] = 5; + + raft_append_result_t out; + memset(&out, 0xEE, sizeof(out)); + const unsigned char e[] = { 0x7 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6, + /*prev_log_index=*/2, /*prev_log_term=*/9, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + /* term adopted even though logOk fails */ + TEST_ASSERT_EQUAL_UINT64(6, s.term); + TEST_ASSERT_EQUAL_INT(-1, s.voted_for); + TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + /* persisted (term=6, voted_for=-1) at least once */ + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count); + TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_persist_term); + TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for); + /* reply carries the adopted term; the RPC is still rejected (logOk failed) */ + TEST_ASSERT_EQUAL_UINT64(6, out.reply_term); + TEST_ASSERT_EQUAL_INT(0, out.success); + TEST_ASSERT_EQUAL_UINT64(0, out.match_index); +} + +/* + * test_ae_req_leader_adopts_higher_term_steps_down (task 5.1/5.2, Req 6.1, 6.3) + * + * A LEADER at term T receiving an AppendEntries at term T+1 must adopt T+1, + * clear vote, step down to FOLLOWER, persist (T+1,-1), AND fire on_become_follower + * with (T+1, leader_idx). Applies before logOk. + */ +static void test_ae_req_leader_adopts_higher_term_steps_down(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0)); + /* drive to LEADER at term 1, then a higher-term AE arrives */ + drive_to_leader(&s); + TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role); + int bf_before = g_mock.become_follower_count; + uint64_t higher = s.term + 1; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + /* heartbeat (entries_len=0, prev_log_index=0 → logOk trivially true) */ + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/2, higher, + /*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_UINT64(higher, s.term); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); + TEST_ASSERT_EQUAL_INT(-1, s.voted_for); + /* on_become_follower fired once for the step-down with (higher, leader_idx=2) */ + TEST_ASSERT_EQUAL_INT(bf_before + 1, g_mock.become_follower_count); + TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_become_follower_term); + TEST_ASSERT_EQUAL_INT(2, g_mock.last_become_follower_leader_idx); + /* persisted the adopted term */ + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count); + TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_persist_term); + TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for); + /* the higher-term heartbeat is accepted (logOk trivially true) */ + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(higher, out.reply_term); +} + +/* + * test_ae_req_heartbeat_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.4 — G5) + * + * A 0-entry heartbeat at a strictly-higher term still triggers UpdateTerm + * (adoption applies on EVERY RPC incl. heartbeat), even when the heartbeat's + * prev_log_index would fail logOk on the local log. + */ +static void test_ae_req_heartbeat_adopts_higher_term(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 1)); + s.term = 3; + s.role = RAFT_FOLLOWER; + /* logOk fails: prev_log_index=1 expects term 8 but local entry 1 is term 3 */ + g_gt_spy.term_at[1] = 3; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7, + /*prev_log_index=*/1, /*prev_log_term=*/8, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + /* adopted despite logOk failure */ + TEST_ASSERT_EQUAL_UINT64(7, s.term); + TEST_ASSERT_EQUAL_INT(-1, s.voted_for); + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count); + TEST_ASSERT_EQUAL_UINT64(7, g_mock.last_persist_term); + /* heartbeat with a failing logOk is rejected, but at the adopted term */ + TEST_ASSERT_EQUAL_INT(0, out.success); + TEST_ASSERT_EQUAL_UINT64(7, out.reply_term); + TEST_ASSERT_EQUAL_UINT64(0, out.match_index); +} + +/* + * test_ae_req_equal_term_no_term_change (Req 6.5) + * + * On an EQUAL-term AppendEntries, the handler must NOT change term or voted_for, + * and must NOT persist a term adoption. + */ +static void test_ae_req_equal_term_no_term_change(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0)); + s.term = 4; + s.role = RAFT_FOLLOWER; + s.voted_for = 2; + s.voted_for_term = 4; + int persist_before = g_mock.persist_count; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + /* empty log, prev_log_index=0 → logOk true → append branch (heartbeat) */ + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4, + /*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_UINT64(4, s.term); + TEST_ASSERT_EQUAL_INT(2, s.voted_for); /* untouched */ + TEST_ASSERT_EQUAL_UINT64(4, s.voted_for_term); + /* no on_persist for term adoption on equal term */ + TEST_ASSERT_EQUAL_INT(persist_before, g_mock.persist_count); + TEST_ASSERT_EQUAL_INT(1, out.success); /* equal-term heartbeat accepted */ +} + +/* + * test_ae_req_stale_term_rejected (task 6.1, Req 4.2) + * + * term < currentTerm → reject (success=0, match_index=0, reply_term=currentTerm), + * no term change, no log mutation. + */ +static void test_ae_req_stale_term_rejected(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2)); + s.term = 9; + s.role = RAFT_FOLLOWER; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0x1 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/3, + /*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/3, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(0, out.success); + TEST_ASSERT_EQUAL_UINT64(0, out.match_index); + TEST_ASSERT_EQUAL_UINT64(9, out.reply_term); /* current term */ + TEST_ASSERT_EQUAL_UINT64(9, s.term); /* unchanged */ + TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no log write */ + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */ +} + +/* + * test_ae_req_logok_prev_zero_accepts_append (task 6.1/6.3, Req 4.1, 4.6) + * + * prev_log_index == 0 → logOk true unconditionally; the local log ends at 0 + * (empty) so the one incoming entry is appended via on_persist_entry at index 1; + * success, match_index = prev_log_index + entries_len = 1; log_len grows to 1. + */ +static void test_ae_req_logok_prev_zero_accepts_append(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0)); + s.term = 2; + s.role = RAFT_FOLLOWER; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0xAA, 0xBB }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/2, + /*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/2, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* prev(0) + entries_len(1) */ + /* appended at index 1 with the incoming entry term */ + TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(1, g_pe_spy.last_index); + TEST_ASSERT_EQUAL_UINT64(2, g_pe_spy.last_term); + TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e)); + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* append, no truncate */ + TEST_ASSERT_EQUAL_UINT64(1, s.log_len); /* log extended */ +} + +/* + * test_ae_req_logok_match_accepts (task 6.1/6.3, Req 4.1) + * + * prev_log_index > 0 and on_get_entry_term(prev_log_index) == prev_log_term → + * logOk true. Append at index prev+1 (local log ends at prev) → success, + * match_index = prev + entries_len. + */ +static void test_ae_req_logok_match_accepts(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3)); + s.term = 4; + s.role = RAFT_FOLLOWER; + /* local entry 3 has term 4 == prev_log_term ⇒ logOk true; index 4 absent */ + g_gt_spy.term_at[3] = 4; + /* g_gt_spy.term_at[4] == 0 → absent → append branch */ + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0xC0 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4, + /*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */ + TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index); + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(4, s.log_len); +} + +/* + * test_ae_req_logok_mismatch_rejected (task 6.1, Req 4.1, 4.2 — G2) + * + * prev_log_index > 0 and on_get_entry_term(prev_log_index) != prev_log_term → + * logOk FALSE → follower at equal term rejects (success=0, match_index=0), + * with no log mutation. + */ +static void test_ae_req_logok_mismatch_rejected(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3)); + s.term = 4; + s.role = RAFT_FOLLOWER; + /* local entry 3 has term 2, but prev_log_term says 4 → logOk false */ + g_gt_spy.term_at[3] = 2; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0xD0 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4, + /*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(0, out.success); + TEST_ASSERT_EQUAL_UINT64(0, out.match_index); + TEST_ASSERT_EQUAL_UINT64(4, out.reply_term); + TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* unchanged */ +} + +/* + * test_ae_req_candidate_equal_term_steps_down (task 6.1, Req 4.3) + * + * A CANDIDATE at EQUAL term receiving a logOk AppendEntries transitions to + * FOLLOWER (return-to-follower branch) and processes the accept. + */ +static void test_ae_req_candidate_equal_term_steps_down(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0)); + /* become CANDIDATE at term 1 */ + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + uint64_t cand_term = s.term; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + /* equal-term heartbeat, prev_log_index=0 → logOk true */ + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term, + /*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down */ + TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* no term change (equal) */ + TEST_ASSERT_EQUAL_INT(1, out.success); /* heartbeat accepted */ +} + +/* + * test_ae_req_candidate_equal_term_steps_down_then_rejects (task 6.1, Req 4.2/4.3) + * + * A CANDIDATE at EQUAL term receiving an AppendEntries whose prev entry does + * NOT match (logOk false) must still step down to FOLLOWER (step 3, before + * logOk) AND then reject (step 5). This pins the step ordering: a mutation that + * moved candidate step-down after the logOk reject would leave the node a + * CANDIDATE here and be caught. + */ +static void test_ae_req_candidate_equal_term_steps_down_then_rejects(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5)); + TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s)); + TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role); + uint64_t cand_term = s.term; + + /* prev_log_index=3 within log, but local term there (4) != prev_log_term (9) + * → logOk false → reject, yet the candidate must have stepped down first. */ + g_gt_spy.term_at[3] = 4; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term, + /*prev_log_index=*/3, /*prev_log_term=*/9, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down BEFORE logOk */ + TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* equal term, no change */ + TEST_ASSERT_EQUAL_INT(0, out.success); /* logOk failed → reject */ + TEST_ASSERT_EQUAL_UINT64(0, out.match_index); /* reject → match_index 0 */ + TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no persist on reject */ + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on reject */ +} + +/* + * test_ae_req_already_have_heartbeat (task 6.2, Req 4.4) + * + * entries_len == 0 (heartbeat) with logOk → already-satisfied: success, + * match_index = prev + 0 (= prev), NO log rewrite, NO truncate. + */ +static void test_ae_req_already_have_heartbeat(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5)); + s.term = 6; + s.role = RAFT_FOLLOWER; + g_gt_spy.term_at[5] = 6; /* prev_log_index=5 matches prev_log_term=6 */ + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6, + /*prev_log_index=*/5, /*prev_log_term=*/6, /*leader_commit=*/0, + NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(5, out.match_index); /* prev(5) + entries_len(0) */ + TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no rewrite */ + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */ + TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* heartbeat must NOT shrink log */ +} + +/* + * test_ae_req_already_have_matching_term (task 6.2, Req 4.4) + * + * entries_len == 1 but the local entry at prev+1 already has the SAME term as the + * incoming entry → already-satisfied: success, match_index = prev + entries_len, + * NO log rewrite, NO truncate. + */ +static void test_ae_req_already_have_matching_term(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5)); + s.term = 6; + s.role = RAFT_FOLLOWER; + g_gt_spy.term_at[3] = 6; /* prev_log_index=3 matches prev_log_term=6 (logOk) */ + g_gt_spy.term_at[4] = 6; /* local entry at index 4 already term 6 == entry_term */ + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0x55 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6, + /*prev_log_index=*/3, /*prev_log_term=*/6, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */ + TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* already have it — no rewrite */ + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* must NOT shrink */ +} + +/* + * test_ae_req_conflict_truncate_then_persist (task 6.2/6.3, Req 4.5 — G3) + * + * logOk holds, but the local entry at index = prev+1 exists with a DIFFERENT term + * than the incoming entry → on_truncate_from(index) THEN on_persist_entry(index, + * entry_term, entry); success, match_index = index (= prev + entries_len). + * The truncate MUST be immediately followed by the re-persist of the leader entry. + */ +static void test_ae_req_conflict_truncate_then_persist(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5)); + s.term = 7; + s.role = RAFT_FOLLOWER; + g_gt_spy.term_at[3] = 7; /* prev_log_index=3, prev_log_term=7 → logOk */ + g_gt_spy.term_at[4] = 4; /* local entry 4 has term 4 (divergent) */ + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0x99, 0xAA }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7, + /*prev_log_index=*/3, /*prev_log_term=*/7, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/7, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* index = prev+1 */ + /* truncate at the conflicting index, then re-persist the leader's entry there */ + TEST_ASSERT_EQUAL_INT(1, g_tr_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(4, g_tr_spy.last_index); + TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index); + TEST_ASSERT_EQUAL_UINT64(7, g_pe_spy.last_term); /* the leader's entry_term */ + TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e)); + /* log_len is at least prev + entries_len after a conflict-and-append */ + TEST_ASSERT_EQUAL_UINT64(4, s.log_len); +} + +/* + * test_ae_req_append_extends_log (task 6.3, Req 4.6) + * + * logOk holds and the local log ends exactly at prev_log_index (no entry at + * prev+1) → append via on_persist_entry; success; match_index = prev + entries_len; + * log_len extended to prev + entries_len. + */ +static void test_ae_req_append_extends_log(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2)); + s.term = 5; + s.role = RAFT_FOLLOWER; + g_gt_spy.term_at[2] = 5; /* prev_log_index=2, prev_log_term=5 → logOk */ + /* index 3 absent (term_at[3]==0), log_len==2 == prev_log_index → append */ + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0x11, 0x22, 0x33 }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/5, + /*prev_log_index=*/2, /*prev_log_term=*/5, /*leader_commit=*/0, + e, sizeof(e), /*entry_term=*/5, /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(3, out.match_index); + TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on plain append */ + TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count); + TEST_ASSERT_EQUAL_UINT64(3, g_pe_spy.last_index); + TEST_ASSERT_EQUAL_UINT64(5, g_pe_spy.last_term); + TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* extended */ +} + +/* + * test_ae_req_leader_commit_not_acted (Req 4.x / R3.4 — commit opaque) + * + * A large leader_commit must NOT advance any commit index or otherwise leak into + * library state — the handler only fills *out and mutates the log per the accept + * branch. (No commit field exists in raft_state_t; this asserts the accept path + * is unaffected by leader_commit value.) + */ +static void test_ae_req_leader_commit_not_acted(void) +{ + raft_state_t s; + raft_callbacks_t cbs = make_ae_req_cbs(); + + reset_ae_req_spies(); + TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0)); + s.term = 1; + s.role = RAFT_FOLLOWER; + + raft_append_result_t out; + memset(&out, 0, sizeof(out)); + const unsigned char e[] = { 0x7E }; + int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/1, + /*prev_log_index=*/0, /*prev_log_term=*/0, + /*leader_commit=*/999999, e, sizeof(e), /*entry_term=*/1, + /*entries_len=*/1, &out); + + TEST_ASSERT_EQUAL_INT(RAFT_OK, rc); + TEST_ASSERT_EQUAL_INT(1, out.success); + TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* unaffected by leader_commit */ + TEST_ASSERT_EQUAL_UINT64(1, s.log_len); +} + /* ---------- raft_on_election_timeout tests (task 3.5) ---------- */ /* @@ -4501,6 +5098,24 @@ void test_raft(void) RUN_TEST(test_send_ae_one_entry_when_next_within_log); RUN_TEST(test_send_ae_non_leader_emits_nothing); + /* raft_on_append_entries_request (tasks 5.1+5.2 UpdateTerm-first, 6.1 logOk + * +reject+candidate-step-down, 6.2+6.3 accept already-have/conflict/append) */ + RUN_TEST(test_ae_req_follower_adopts_higher_term); + RUN_TEST(test_ae_req_leader_adopts_higher_term_steps_down); + RUN_TEST(test_ae_req_heartbeat_adopts_higher_term); + RUN_TEST(test_ae_req_equal_term_no_term_change); + RUN_TEST(test_ae_req_stale_term_rejected); + RUN_TEST(test_ae_req_logok_prev_zero_accepts_append); + RUN_TEST(test_ae_req_logok_match_accepts); + RUN_TEST(test_ae_req_logok_mismatch_rejected); + RUN_TEST(test_ae_req_candidate_equal_term_steps_down); + RUN_TEST(test_ae_req_candidate_equal_term_steps_down_then_rejects); + RUN_TEST(test_ae_req_already_have_heartbeat); + RUN_TEST(test_ae_req_already_have_matching_term); + RUN_TEST(test_ae_req_conflict_truncate_then_persist); + RUN_TEST(test_ae_req_append_extends_log); + RUN_TEST(test_ae_req_leader_commit_not_acted); + /* raft_on_vote_request (task 3.3) */ RUN_TEST(test_vote_req_stale_term); RUN_TEST(test_vote_req_higher_term_updates_state);