fix(raft-ha-core-safety): (term,lsn,payload-crc)-aware persist guard (task 2.2)

Payload-aware repair: identical payload = idempotent no-op; non-empty
differing payload at the TIP = repair (truncate-to-lsn-1 + re-append,
committed_lsn snapshot/restored under lock); empty-over-stored = keep
(never degrade); BELOW-tip conflict = loud non-promotable fence
(recovery_unrecoverable), never truncate the tail or lower committed_lsn.
read_entry_meta_locked computes payload-crc identically to walcompare.
Turns 2.1 case (a) green; new below-tip regression test pins the
committed-boundary safety. Review round 1 caught + fixed a committed-data-
loss bug (tail-truncate on below-tip conflict). 8/8 target suite, sibling
persist/repl/catchup/durability suites green; persist-before-ACK intact.
This commit is contained in:
Rongsong Shen 2026-06-27 10:57:37 +00:00
parent 3b5eda1429
commit 056675c8b3
3 changed files with 335 additions and 14 deletions

View file

@ -388,6 +388,89 @@ static void test_2_1_identical_rewrite_is_idempotent_noop(void)
printf("test_2_1_identical_rewrite_is_idempotent_noop: PASS (pin)\n");
}
/* ═══════════════════════════════════════════════════════════════════════════
* TASK 2.2 REMEDIATION (round 1) — below-tip payload conflict must NOT drop
* committed entries above the conflict, and must NOT regress committed_lsn.
*
* The round-1 repair fired truncate-and-reappend for ANY lsn < current_lsn,
* which (when the conflict sits BELOW the stored tip — an empty body at a
* committed LSN with committed entries ABOVE it) deleted those committed
* entries and rolled committed_lsn backward — a State Machine Safety /
* commit-monotonicity violation on the still-live term-blind apply route.
*
* Construct exactly that shape: entries [1..N] (N=4) with an empty (PLEN=0)
* body at k=2 (k < N, so entries 3,4 sit ABOVE the conflict), committed_lsn=N.
* Stream the CORRECT full body at lsn=k (the corrective replicated write).
* The fix must NOT truncate the tail here: assert (a) entries k+1..N survive,
* and (b) committed_lsn is unchanged. This FAILs against the round-1 version
* (entries 3,4 dropped, committed_lsn rolled to k-1) and PASSes after the fix
* (below-tip conflict → loud non-promotable fence, stored entry kept).
* ═══════════════════════════════════════════════════════════════════════════ */
static void test_2_2_below_tip_conflict_keeps_committed_entries(void)
{
hcs_member_t m;
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "btip"));
enum { N = 4, K = 2 };
uint8_t full[HCS_FULL_LEN];
hcs_fill_body(full);
/* Build entries [1..N] contiguously through the real replicated guard, with
* the entry at K written EMPTY (PLEN=0) — the live cross-term catch-up
* payload-loss signature, with committed entries K+1..N stored ABOVE it. */
for (uint64_t lsn = 1; lsn <= N; lsn++) {
const void* body = (lsn == K) ? NULL : (const void*)full;
uint32_t blen = (lsn == K) ? 0u : HCS_FULL_LEN;
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
hcs_wal_put(&m, lsn, /*term=*/3, WAL_JOB_SUBMIT, body, blen),
"each contiguous build write must persist (real fs required — bug-924)");
}
/* The whole prefix [1..N] is committed. */
atomic_store_explicit(&m.p->committed_lsn, (uint64_t)N, memory_order_release);
/* Sanity: entries K+1..N are present with the full payload before the repair. */
for (uint64_t lsn = K + 1; lsn <= N; lsn++) {
hcs_probe_t pr;
TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, lsn, &pr));
TEST_ASSERT_TRUE_MESSAGE(pr.present, "above-conflict entry must exist pre-repair");
TEST_ASSERT_EQUAL_UINT32(HCS_FULL_LEN, pr.len);
}
/* The corrective FULL body is streamed for the SAME (term, lsn=K) — a
* non-empty payload that differs from the stored empty body, BELOW the tip. */
int rc = hcs_wal_put(&m, /*lsn=*/K, /*term=*/3, WAL_JOB_SUBMIT, full, HCS_FULL_LEN);
TEST_ASSERT_TRUE_MESSAGE(rc == 0,
"the below-tip conflict write must be a loud no-op (rc==0), never an error "
"that re-streams destructively");
/* (a) Entries K+1..N must STILL be present — the round-1 truncate dropped
* them; the fix must keep every committed entry above the conflict. */
for (uint64_t lsn = K + 1; lsn <= N; lsn++) {
hcs_probe_t pr;
TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, lsn, &pr));
TEST_ASSERT_TRUE_MESSAGE(pr.present,
"REGRESSION GUARD: a below-tip payload repair must NOT drop committed "
"entries stored ABOVE the conflict LSN (round-1 truncate deleted them)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(HCS_FULL_LEN, pr.len,
"above-conflict committed entry must keep its full payload");
}
/* (b) committed_lsn must NOT be lowered — round-1 rolled it back to K-1. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE((uint64_t)N,
atomic_load_explicit(&m.p->committed_lsn, memory_order_acquire),
"REGRESSION GUARD: a below-tip payload repair must NOT regress committed_lsn "
"(commit monotonicity / State Machine Safety)");
/* The below-tip conflict raises the loud non-promotable fence (stays ALIVE,
* self-heals via catch-up) rather than corrupting committed state. */
TEST_ASSERT_TRUE_MESSAGE(persist_recovery_unrecoverable(m.p),
"a below-tip payload conflict must raise the non-promotable fence");
hcs_member_down(&m);
printf("test_2_2_below_tip_conflict_keeps_committed_entries: PASS\n");
}
/* ═══════════════════════════════════════════════════════════════════════════
* SCAFFOLDING (PASS) — prove each shared seam is usable by later phases.
* ═══════════════════════════════════════════════════════════════════════════ */
@ -519,6 +602,9 @@ void test_ha_core_safety(void)
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);
/* Task 2.2 remediation: a below-tip payload conflict must not drop
* committed entries above it nor regress committed_lsn. */
RUN_TEST(test_2_2_below_tip_conflict_keeps_committed_entries);
/* 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);