diff --git a/.kiro/specs/raft-ha-core-safety/tasks.md b/.kiro/specs/raft-ha-core-safety/tasks.md index e08e862..2dc12b9 100644 --- a/.kiro/specs/raft-ha-core-safety/tasks.md +++ b/.kiro/specs/raft-ha-core-safety/tasks.md @@ -40,7 +40,7 @@ - [ ] 2. Committed-prefix divergence fix: cross-term catch-up payload integrity + payload-aware guard - _Root cause (task 1.3, verified): the cross-term catch-up streamer silently ships `PLEN=0` on a short `pread`/`malloc`-fail, and the `(term,lsn)`-only persist guard is payload-blind. Term and ingest-path are correct — the payload is lost._ -- [ ] 2.1 RED: payload-aware persistence guard tests +- [x] 2.1 RED: payload-aware persistence guard tests - Add cases: an empty-payload (`PLEN=0`) entry written at an existing LSN N followed by the correct full-payload entry must end with the FULL payload (today the `(term,lsn)`-only idempotency guard silently no-ops the corrective write); the reverse (full then empty at the same `(term,lsn)`) must NOT degrade the stored entry to empty; an identical `(term, payload-crc)` re-write is a safe idempotent no-op - Observable completion: the new persistence cases FAIL on the baseline because the guard compares only `(term, lsn)` and cannot see the payload difference - _Requirements: 1.1, 1.2, 2.1, 2.2, 3.1, 3.4_ diff --git a/src/controller/testsuite/test_ha_core_safety.c b/src/controller/testsuite/test_ha_core_safety.c index c64978f..7205565 100644 --- a/src/controller/testsuite/test_ha_core_safety.c +++ b/src/controller/testsuite/test_ha_core_safety.c @@ -276,6 +276,118 @@ static void test_smoke_red_payload_blind_guard_drops_corrective_write(void) printf("test_smoke_red_payload_blind_guard_drops_corrective_write: done\n"); } +/* ═══════════════════════════════════════════════════════════════════════════ + * TASK 2.1 — payload-aware persistence guard cases (a)/(b)/(c) + * + * These three cases pin the post-fix contract task 2.2 must satisfy when it + * makes the LSN-only idempotency guard (persist.c:822) (term, lsn, payload-crc) + * -aware. They read as one coherent group: + * + * (a) empty-then-full at the SAME (term, lsn) → must end FULL. + * This is the RED case, owned by the smoke-RED above + * (test_smoke_red_payload_blind_guard_drops_corrective_write): the + * payload-blind guard no-ops the corrective write and keeps the empty + * body — FAILS on the baseline, goes GREEN with 2.2. + * + * (b) full-then-empty at the SAME (term, lsn) → must NOT degrade to empty. + * PIN: the baseline already satisfies this — once the full body advances + * current_lsn, the same LSN-only idempotency guard no-ops the later empty + * write, so the stored entry stays full. This case is therefore NOT a + * baseline RED; it is a guard that 2.2's new payload-crc-aware branch does + * not over-correct an empty (PLEN=0) write into a destructive + * overwrite/truncate of a known-non-empty committed entry. + * + * (c) identical (term, payload-crc) re-write → safe idempotent no-op. + * PIN: the baseline already satisfies this (the LSN-only guard no-ops it); + * it guards 2.2 against treating an identical re-stream as a "conflict" + * and over-truncating/duplicating a correct committed entry. + * ═══════════════════════════════════════════════════════════════════════════ */ + +/* Case (b): a stray empty (PLEN=0) write at an existing committed (term, lsn) + * must NEVER degrade the stored full entry to empty. Baseline-passing PIN + * (the LSN-only idempotency guard already no-ops the empty write). */ +static void test_2_1_full_then_empty_must_not_degrade(void) +{ + hcs_member_t m; + TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "fte")); + + uint8_t full[HCS_FULL_LEN]; + hcs_fill_body(full); + + /* (1) the CORRECT full body lands first at committed LSN 1. */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT, full, HCS_FULL_LEN), + "the full replicated write must persist (needs a real fs for the inline " + "fdatasync; -EIO here means overlayfs — bug-924)"); + + /* (2) a stray empty (PLEN=0) body arrives for the SAME (term, lsn). */ + int rc_empty = hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT, + /*payload=*/NULL, /*len=*/0); + TEST_ASSERT_TRUE_MESSAGE(rc_empty == 0, + "the stray empty write returns 0 (baseline: an idempotent no-op; " + "fixed: a rejected/repaired conflict) — either way rc==0"); + + /* (3) OBSERVE: on disk the entry must STILL hold the FULL payload — an + * empty body must never become the durable form of a known-non-empty + * committed entry (the inverse of case (a)). */ + hcs_probe_t pr; + TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 1, &pr)); + TEST_ASSERT_TRUE_MESSAGE(pr.present, + "committed LSN 1 must remain present after the stray empty write"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(HCS_FULL_LEN, pr.len, + "PIN: an empty (PLEN=0) write must NOT degrade the stored full entry to " + "empty (guards 2.2 against over-correcting into a destructive overwrite)"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(0u, pr.pcrc, + "PIN: the committed entry must keep its real payload CRC, not drop to 0"); + + hcs_member_down(&m); + printf("test_2_1_full_then_empty_must_not_degrade: PASS (pin)\n"); +} + +/* Case (c): re-streaming the IDENTICAL (term, payload-crc) entry at the same + * committed LSN is a safe idempotent no-op — no degrade, no duplicate, no + * CRC-chain break. Baseline-passing PIN. */ +static void test_2_1_identical_rewrite_is_idempotent_noop(void) +{ + hcs_member_t m; + TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "idem")); + + uint8_t full[HCS_FULL_LEN]; + hcs_fill_body(full); + + /* (1) the full body lands at committed LSN 1. */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT, full, HCS_FULL_LEN), + "the initial full write must persist (real fs required — bug-924)"); + hcs_probe_t before; + TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 1, &before)); + TEST_ASSERT_TRUE(before.present); + TEST_ASSERT_EQUAL_UINT32(HCS_FULL_LEN, before.len); + TEST_ASSERT_NOT_EQUAL(0u, before.pcrc); + + /* (2) the IDENTICAL (term, payload-crc) entry is re-streamed at the same + * (term, lsn) — must be accepted as a no-op, never a conflict. */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT, full, HCS_FULL_LEN), + "an identical (term, payload-crc) re-write must be a safe idempotent no-op"); + + /* (3) OBSERVE: the stored entry is unchanged — same length and same + * (term, payload-crc) fingerprint as before the re-write. */ + hcs_probe_t after; + TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 1, &after)); + TEST_ASSERT_TRUE(after.present); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(HCS_FULL_LEN, after.len, + "PIN: an identical re-write must leave the full payload length intact"); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(before.term, after.term, + "PIN: an identical re-write must leave the stored term unchanged"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(before.pcrc, after.pcrc, + "PIN: an identical re-write must leave the stored payload CRC unchanged " + "(no degrade, no over-truncate)"); + + hcs_member_down(&m); + printf("test_2_1_identical_rewrite_is_idempotent_noop: PASS (pin)\n"); +} + /* ═══════════════════════════════════════════════════════════════════════════ * SCAFFOLDING (PASS) — prove each shared seam is usable by later phases. * ═══════════════════════════════════════════════════════════════════════════ */ @@ -400,8 +512,13 @@ static void test_seam_election_injection(void) void test_ha_core_safety(void) { UNITY_BEGIN(); - /* The ONE smoke-RED: payload-blind guard drops the corrective write. */ + /* Task 2.1 payload-aware guard set: + * (a) empty-then-full — RED (the smoke-RED below): FAILs on the baseline. + * (b) full-then-empty — PIN: must not degrade (baseline-passing). + * (c) identical re-write — PIN: idempotent no-op (baseline-passing). */ RUN_TEST(test_smoke_red_payload_blind_guard_drops_corrective_write); + RUN_TEST(test_2_1_full_then_empty_must_not_degrade); + RUN_TEST(test_2_1_identical_rewrite_is_idempotent_noop); /* Scaffolding: each shared seam is usable by 2.3 / 4.1 / 5.1. */ RUN_TEST(test_seam_wal_construct_and_probe); RUN_TEST(test_seam_divergence_is_observable);