feat(ha-term-safety): WAL term seam + recovery read-back + legacy-0 rule (task 1.3)

persist_set_term sets the live election term so appends stamp hdr.term (fixes the
G1 term-0 stamping); replay_wal builds an in-memory per-LSN term index (freed in
persist_fini, no on-disk change); persist_entry_term reads it back (0=absent).
Legacy-0 -> WAL_LEGACY_TERM=1 at read-back only (raw 0 retained so present-legacy-0
is distinguishable from absent). 40-byte wal_entry_hdr_t preserved (value fix, R1.7).
5 new tests (test_term_safety_adapter.c); reviewer APPROVED. Feeds task 2.2.

All controller test groups report 0 failures; the meson full-suite timeout is the
bug-924 overlayfs /tmp fdatasync artifact, not this task.
This commit is contained in:
Rongsong Shen 2026-06-22 04:32:29 +00:00
parent a95d57f733
commit d540ebd183
6 changed files with 420 additions and 1 deletions

View file

@ -171,6 +171,23 @@ struct persist_s {
uint64_t total_wal_bytes;
char seg_path[4096];
/* Per-LSN recovered/live term index (ha-term-safety task 1.3, R1.5).
* IN-MEMORY ONLY — no on-disk layout change (R1.7). Records each entry's
* hdr.term keyed by LSN so the term can be read back after recovery and
* fed to the reconcile/promotion logic (on_get_entry_term, task 2.2).
*
* term_index[lsn - 1] holds the RAW recorded term for a 1-based LSN (LSN 0
* is reserved and never indexed). term_index_count is the highest LSN
* recorded (== number of contiguous slots in use), used as the presence
* watermark so persist_entry_term distinguishes an ABSENT LSN (returns 0)
* from a PRESENT legacy-0 entry (mapped to the lowest-known term). Grown on
* demand by record_entry_term during replay_wal and live appends; freed in
* persist_fini. Guarded by p->lock (written under the append lock and the
* single-threaded recovery path; read by accessors that take p->lock). */
uint64_t* term_index; /* term_index[i] = term of LSN (i+1) */
uint64_t term_index_count; /* highest recorded LSN (slots in use) */
uint64_t term_index_cap; /* allocated slot capacity */
/* Recovery-integrity verdict (ha-commit-durability task 3.1, Req 2.1).
* Set by persist_recover/replay_wal at startup: RECOVER_CLEAN when the
* local WAL forms a valid CRC chain to a clean entry boundary (incl. an
@ -271,6 +288,45 @@ typedef struct persist_s persist_t;
* Derives wal_path and snap_path as <workdir>/<name>/data/{wal,snap}. */
void persist_set_workdir(persist_t* p, const char* workdir, const char* name);
/* ============================================================
* WAL term adapter (ha-term-safety task 1.3, Req 1.1/1.5/1.6/1.7/1.8)
*
* Today every WAL append stamps hdr.term = p->term, but p->term is never
* assigned, so all entries carry term 0 (the G1 bug). The on-disk
* wal_entry_hdr_t already has a 40-byte `term` field; this is a VALUE fix, NOT
* a layout change (R1.7).
* ============================================================ */
/* Legacy-zero-term rule (R1.8) — REVIEW ISSUE 2, made precise:
*
* A WAL entry that predates this feature carries hdr.term == 0. For (term,lsn)
* matching it must map to a SINGLE, well-defined "lowest-known term" so that
* (a) recovery never fails on legacy-0 entries, and
* (b) a legacy-0 entry is never spuriously treated as divergent from another
* legacy-0 entry at the same LSN.
* The rule: legacy-0 -> WAL_LEGACY_TERM (1, the lowest real Raft term — Raft
* terms start at 1; term 0 is the pre-election "no term" placeholder). It is
* applied UNIFORMLY at read-back (persist_entry_term), so every legacy-0 entry
* answers the same term 1 and matches another legacy-0 entry at the same LSN.
* The raw 0 is still stored in the index (so "present legacy-0" stays
* distinguishable from "absent"); only the read-back value is mapped. */
#define WAL_LEGACY_TERM 1u
/* Set the controller's live election term so subsequent WAL appends stamp it
* into hdr.term (R1.1/R1.6). Called from the append callback (on_persist_entry,
* task 2.2) and on term adoption, so no append records a zero placeholder while
* a valid term is in effect. No on-disk layout change; value fix only (R1.7).
* Safe on a NULL p (no-op). */
void persist_set_term(persist_t* p, uint64_t term);
/* Recovered/live per-entry term for a 1-based LSN (lsn == Raft index), feeding
* the task-2.2 on_get_entry_term callback and the library's init restore
* (R1.5). Returns 0 when the LSN is ABSENT (never recorded). A PRESENT entry
* that carries a legacy zero term is mapped to WAL_LEGACY_TERM (R1.8) so it is
* never confused with an absent LSN and matches other legacy-0 entries.
* Safe on a NULL p (returns 0). */
uint64_t persist_entry_term(const persist_t* p, uint64_t lsn);
/* Lifecycle */
int persist_init(persist_t* p, persist_config_t* config, cluster_t* c);
int persist_start(persist_t* p);