diff --git a/.kiro/specs/ha-repl-hardening/tasks.md b/.kiro/specs/ha-repl-hardening/tasks.md index d3107ea3..fa650c69 100644 --- a/.kiro/specs/ha-repl-hardening/tasks.md +++ b/.kiro/specs/ha-repl-hardening/tasks.md @@ -155,7 +155,7 @@ - _Requirements: 2.3_ - _Boundary: Controller FSM (ha.c)_ -- [ ] 4.2 Replace stale-ctrlhello truncation with vote-derived safe commit point +- [x] 4.2 Replace stale-ctrlhello truncation with vote-derived safe commit point - Remove `persist_truncate_wal_above(last_known_committed_lsn)` from the become-leader path - Replace with `persist_truncate_wal_above(safe_committed_lsn)` using the value from task 4.1 - Set `committed_lsn = safe_committed_lsn` after truncation diff --git a/src/controller/ha.c b/src/controller/ha.c index 5a1b4f8c..274d06ab 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -468,6 +468,17 @@ static void raft_cb_become_leader(void *ctx, uint64_t term) if (c) { persist_truncate_wal_above(&c->persist, safe_lsn); } + /* Task 4.2 (ha-repl-hardening): set committed_lsn = safe_lsn. + * persist_truncate_wal_above already sets c->persist.committed_lsn + * in the production path (c != NULL). Also update + * ha->last_known_committed_lsn so the test-mode decision loop + * (XCE_TESTING path in standby heartbeat reply handler) and any + * code reading ha->last_known_committed_lsn see the safe commit + * point immediately after promotion. + * Design §Flow 0 step 5: committed_lsn = safe_committed_lsn. + * Req 2.3 (ha-repl-hardening). */ + atomic_store_explicit(&ha->last_known_committed_lsn, safe_lsn, + memory_order_release); /* Update committed config tag baseline for the COMMITTING decision loop */ atomic_store_explicit(&ha->committed_config_tag, safe_tag, memory_order_release); diff --git a/src/controller/testsuite/test_ha_evaluate_committing.c b/src/controller/testsuite/test_ha_evaluate_committing.c index daad35e7..f8af96f2 100644 --- a/src/controller/testsuite/test_ha_evaluate_committing.c +++ b/src/controller/testsuite/test_ha_evaluate_committing.c @@ -608,6 +608,110 @@ static void test_safe_committed_lsn_one_peer_denied(void) #endif } +/* ── Task 4.2 (ha-repl-hardening): committed_lsn set to safe_committed_lsn ── + * + * raft_cb_become_leader must set ha->last_known_committed_lsn to the value + * returned by ha_compute_safe_committed_lsn after WAL truncation. + * + * This ensures that in test mode (c == NULL) the decision loop reads the + * correct committed_lsn from ha->last_known_committed_lsn (see decision-loop + * XCE_TESTING path in ha.c). + * + * Requirement 2.3 (ha-repl-hardening): safe_committed_lsn is derived from + * voter replies; entries at or below it survive truncation; entries above are + * removed. + * + * The test uses raft_cb_become_leader_for_test (XCE_TESTING shim) which calls + * raft_cb_become_leader with ha->raft_c = NULL (no real cluster, no WAL I/O). + * The WAL-level truncation behaviour (file entries above safe_lsn deleted) is + * covered by test_persist_truncate.c; this test focuses on the committed_lsn + * field assignment that task 4.2 adds. + * + * Design ref: design.md §Flow 0 step 5: committed_lsn = safe_committed_lsn + */ + +#ifdef XCE_TESTING +extern void raft_cb_become_leader_for_test(ha_state_t *ha, uint64_t term); +#endif + +/* + * Test E: raft_cb_become_leader sets ha->last_known_committed_lsn = safe_lsn. + * + * 3-node cluster (n_other=2, votes_granted=3): + * votes[0]: my_lsn=200, peer_lsn=100, vote_granted=1 + * votes[1]: peer_lsn=150, vote_granted=1 + * + * Sorted granted LSNs desc: [200 (self), 150 (peer1), 100 (peer0)] + * Majority threshold for 3 granted = ceil(3/2) = 2; element at index 1 = 150 + * → safe_committed_lsn = 150 + * + * After raft_cb_become_leader_for_test, ha->last_known_committed_lsn must == 150. + * + * Additionally verify via ha_compute_safe_committed_lsn that entry AT safe_lsn + * (lsn=150) is the boundary: safe_lsn=150 means truncate ABOVE 150 (entries + * 151+ removed); entry at 150 is preserved. + */ +static void test_become_leader_sets_committed_lsn_to_safe_lsn(void) +{ +#ifdef XCE_TESTING + reset_counters(); + + ha_state_t ha; + ha_init(&ha, &g_cfg, 0); + ha.phase2_done = 1; /* skip phase2 in this test */ + + /* Build elect_ctx with 2 peers; safe_lsn will be 150 */ + ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + 2 * sizeof(ctx->votes[0])); + TEST_ASSERT_NOT_NULL(ctx); + ctx->n_peers = 2; + ctx->votes_granted = 3; /* self + 2 peers */ + ctx->n_replied = 2; + pthread_mutex_init(&ctx->lock, NULL); + sem_init(&ctx->done, 0, 0); + + /* votes[0]: self data (my_lsn=200) + peer0 granted at lsn=100 */ + ctx->votes[0].my_lsn = 200; + ctx->votes[0].my_tag = 5; + ctx->votes[0].vote_granted = 1; + ctx->votes[0].peer_lsn = 100; + ctx->votes[0].peer_tag = 5; + + /* votes[1]: peer1 granted at lsn=150 */ + ctx->votes[1].vote_granted = 1; + ctx->votes[1].peer_lsn = 150; + ctx->votes[1].peer_tag = 5; + + /* Verify the safe_lsn computation independently */ + uint64_t safe_lsn = 0, safe_tag = 0; + ha_compute_safe_committed_lsn(ctx, &safe_lsn, &safe_tag); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(150, safe_lsn, + "ha_compute_safe_committed_lsn: voters [100,150]+self=200 → safe_lsn must be 150"); + + /* Wire elect_ctx into ha_state so raft_cb_become_leader can read it */ + atomic_store_explicit(&ha.elect_ctx, (void *)ctx, memory_order_release); + + /* Drive the leader promotion path (c=NULL → no WAL I/O, no phase2) */ + raft_cb_become_leader_for_test(&ha, /*term=*/1); + + /* Task 4.2: ha->last_known_committed_lsn must equal safe_lsn (150) */ + uint64_t committed_lsn = + atomic_load_explicit(&ha.last_known_committed_lsn, memory_order_acquire); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(150, committed_lsn, + "raft_cb_become_leader must set ha->last_known_committed_lsn = safe_lsn (150); " + "WAL entry AT safe_lsn=150 is preserved; entries above 150 are truncated"); + + sem_destroy(&ctx->done); + pthread_mutex_destroy(&ctx->lock); + free(ctx); + ha_fini(&ha); + clear_hook(); + printf("test_become_leader_sets_committed_lsn_to_safe_lsn: PASS " + "(committed_lsn=%" PRIu64 ")\n", committed_lsn); +#else + printf("test_become_leader_sets_committed_lsn_to_safe_lsn: SKIP (non-test build)\n"); +#endif +} + /* ── Suite entry point ───────────────────────────────────────────────────── */ void test_ha_evaluate_committing(void) @@ -627,4 +731,6 @@ void test_ha_evaluate_committing(void) RUN_TEST(test_safe_committed_lsn_two_peers_at_50_and_51); RUN_TEST(test_safe_committed_lsn_single_node); RUN_TEST(test_safe_committed_lsn_one_peer_denied); + /* Task 4.2 (ha-repl-hardening): committed_lsn set to safe_committed_lsn */ + RUN_TEST(test_become_leader_sets_committed_lsn_to_safe_lsn); }