feat(ha-commit-durability): loud bounded FATAL on unrecoverable recovery (task 3.3)

This commit is contained in:
Rongsong Shen 2026-06-18 08:15:22 +00:00
parent 6483ee8afd
commit e3fc42c11e
4 changed files with 324 additions and 9 deletions

View file

@ -35,6 +35,47 @@ typedef enum {
* (consumed by the promotion gate, task 4.3). */
} recover_verdict_t;
/* ============================================================
* Boot-policy recovery decision (ha-commit-durability task 3.3, Req 2.4)
*
* The outcome of evaluating a member's boot/promotion-eligibility state
* against the Boot Policy Decision Table (design.md "Boot Policy Decision
* Table (task 3.2)"). This task (3.3) owns the FATAL terminal; the fence +
* catch-up + release path is task 4.3.
*
* ELIGIBLE — may promote (no fence). Rows 1, 6.
* FENCE — ineligible to promote; catch up from a healthy peer, regain
* eligibility on success (Req 2.6). Rows 2, 3, 5. A reachable
* healthy peer NEVER yields FATAL.
* FATAL — fail loud: cannot recover locally AND cannot catch up within
* the bounded window. Terminate with the truncation/boundary
* logged rather than serve divergent state (Req 2.4). Rows 4, 4b.
*
* AUTHORITATIVE INVARIANT (design.md): the fence is never a silent indefinite
* wait — a truncated/behind member resolves to exactly one of FENCE→ELIGIBLE
* or a BOUNDED FATAL. There is no "fenced forever, silently divergent" state.
* ============================================================ */
typedef enum {
RECOVER_DECISION_ELIGIBLE = 0,
RECOVER_DECISION_FENCE = 1,
RECOVER_DECISION_FATAL = 2,
} recover_decision_t;
/* Policy inputs evaluated at boot / promotion-eligibility (design.md table).
* Modelled as a plain value so the decision is a pure, unit-testable function
* of its inputs — the live catch-up machinery (replication / task 4.3) supplies
* these; this task does not build it. */
typedef struct {
recover_verdict_t verdict; /* RECOVER_CLEAN / RECOVER_TRUNCATED (task 3.1) */
bool durable_below_hwm; /* local durable position < cluster committed HWM */
bool healthy_peer_reachable; /* a healthy peer is reachable for catch-up */
bool catch_up_succeeded; /* catch-up reached a committed-consistent state
* within the bounded window (only meaningful
* when a catch-up was attempted) */
uint64_t durable_floor_lsn; /* highest intact replayed LSN (truncation boundary) */
uint64_t cluster_hwm_lsn; /* cluster committed high-water mark, for logging */
} recover_policy_inputs_t;
/* ============================================================
* Persistence configuration (Part 2.6)
* ============================================================ */
@ -276,6 +317,54 @@ uint64_t persist_durable_lsn(persist_t* p);
recover_verdict_t persist_recovery_verdict(persist_t* p);
bool persist_recovery_truncated(persist_t* p);
/* Bounded catch-up window (ha-commit-durability task 3.3, Req 2.4).
* The FINITE deadline a recovery-truncated / behind member is given to reach a
* committed-consistent state from a healthy peer before the boot policy FATALs.
* The fence must NEVER be an indefinite wait (design.md authoritative
* invariant), so the window is always finite. Default 30s — generous enough for
* a healthy-peer catch-up stream yet bounded; the verification mode overrides it
* via the inputs/timeout the caller (task 4.3 boot path) supplies. */
#define DEFAULT_RECOVERY_CATCHUP_WINDOW_MS 30000u
/* persist_recover_decision (ha-commit-durability task 3.3, Req 2.4).
*
* Pure decision over the Boot Policy Decision Table: maps the policy inputs to
* RECOVER_DECISION_{ELIGIBLE,FENCE,FATAL}. No side effects, no termination — it
* only classifies, so it is unit-testable in isolation. The FATAL terminal is
* acted on by persist_recover_fatal_if_unrecoverable (which logs + terminates).
*
* Contract (design.md rows): a reachable healthy peer NEVER returns FATAL
* (rows 3, 5); FATAL is returned ONLY when the member is truncated or behind
* the HWM, has NO healthy peer, and bounded catch-up did not reach a
* committed-consistent state (rows 4, 4b). */
recover_decision_t persist_recover_decision(const recover_policy_inputs_t* in);
/* persist_recover_fatal_if_unrecoverable (ha-commit-durability task 3.3,
* Req 2.4).
*
* Evaluate persist_recover_decision over `in`; if the outcome is FATAL, log at
* ERROR LOUDLY — INCLUDING the truncation boundary (durable_floor_lsn) and the
* cluster high-water mark — and then TERMINATE (fail loud rather than serve
* divergent state). Returns the decision so a non-FATAL caller can fence/proceed
* (FENCE/ELIGIBLE are acted on by task 4.3).
*
* Termination routes through persist_fatal_hook: in production the hook is NULL
* and the function flushes logs and abort()s (NEVER returns on FATAL); a test
* build may install a hook to capture the FATAL decision + boundary log without
* the process dying (mirrors the ha_fatal_hook seam in ha_convergence.c). */
recover_decision_t
persist_recover_fatal_if_unrecoverable(const recover_policy_inputs_t* in);
/* persist_fatal_hook: test-only seam for the FATAL (abort) contract of
* persist_recover_fatal_if_unrecoverable (ha-commit-durability task 3.3,
* Req 2.4). Mirrors ha_fatal_hook (ha_convergence.c).
*
* When non-NULL, the FATAL path invokes this INSTEAD OF abort(), so a test
* binary can assert the FATAL decision was taken and the truncation boundary
* was logged without the process dying. Production leaves it NULL and actually
* terminates. Signature: void hook(const char *msg). */
extern void (*persist_fatal_hook)(const char* msg);
/* Register a durable-advance wake callback (ha-commit-durability task 2.2).
* The group-commit flusher calls fn(ctx) after it advances durable_lsn and
* after releasing p->lock, so a consumer (the commit engine) can re-evaluate