8.4 KiB
WAL & Persistence Durability
How the controller turns appended state into durable, recoverable, majority-backed state. This is the persistence-layer companion to ha-async-io.md (HA protocol / replication) and the src/controller/CLAUDE.md Protocol Verification Checklist (the authoritative safety gate). Owning code: src/controller/persist.c, src/include/persist.h, src/controller/commit.c. Landed by spec ha-commit-durability (2026-06-18, closes bug-661).
Core Invariant: Acknowledged ⇒ Durable on a Majority
When the controller replies ok to a client, the operation's WAL entry is fdatasync'd on a fault-tolerant majority (the acking member + ⌊N/2⌋ others). The reply path is unchanged — it is gated on the commit barrier; the durability guarantee comes from strengthening what "committed" means, not from a new gate.
The load-bearing rule: a member contributes to the commit majority for an LSN only once that LSN is durable locally. The primary counts its own copy at durable_lsn, never at the appended tip.
durable_lsn — The Prefix Watermark
persist_t.durable_lsn (_Atomic uint64_t) is the highest LSN whose bytes are fdatasync'd locally. It is a prefix watermark, never a per-entry set: durable_lsn ≥ L implies every entry ≤ L is durable (because the WAL is a contiguous single-writer log). Invariants:
durable_lsn <= current_lsn - 1(you cannot be durable past what you have appended).- Monotonic non-decreasing except clamp-on-truncate (
persist_truncate_wal_aboveclampsdurable_lsndown to the new tip). Because the clamp lowersdurable_lsn, a truncate cutoff that lags the true commit point physically destroys committed entries — only ever callpersist_truncate_wal_abovewith the votes-derived post-electionsafe_committed_lsnor the library(term,lsn)conflict point, NEVER with the lagginglast_known_committed_lsn(bug-828). - Advances only after a successful
fdatasync— fail-loud: onfdatasyncerror the watermark holds and an ERROR is logged. Never advance a watermark on a failed sync.
Read it via persist_durable_lsn(p). The commit engine's self-cap (majority_candidate_locked in commit.c) uses exactly this value.
Group-Commit Flusher (the fsync_worker pattern)
fsync_worker is demand-driven, not a timer. On append, persist_wal_async pwrites under p->lock, releases, then signals the flusher. The flusher does one fdatasync covering all pending appends, advances durable_lsn, then wakes commit evaluation. This amortizes one sync across the high-volume WAL_MODE_RETAIN fact stream (job start/finish/fail/rusage).
Two correctness rules when touching this path:
- Lost-wakeup guard is level-triggered on the appended-vs-synced gap (
durable_lsn < current_lsn - 1), re-checked underp->lockbefore waiting and after each flush — never edge-triggered. An entry must never be considered durable before itsfdatasync. - Commit wake-up via callback, not a back-reference. The flusher calls
on_durable_advance(ctx)(registered withpersist_set_durable_cb, ctx = thecommit_engine_t) only whendurable_lsnadvanced, and after releasingp->lock. Lock order is strictlyp->lock → ce->lock(cycle-free; persistence never includes commit). On shutdown, join the flusher beforecommit_engine_finior the callback can fire into freed state.
--wal-sync-ms (default DEFAULT_WAL_SYNC_MS = 10) is the maximum batching delay, a latency/throughput tunable — it is never a durability gap. An acked op is durable regardless of its value (a large value just widens the batch window; the commit barrier still waits for durable_lsn).
Standby Persist-Before-ACK
A standby fdatasyncs a replicated entry before it sends its WalAck. persist_wal_write_replicated rc-gates the inline sync and returns -EIO on failure; the caller only ACKs on rc == 0. A standby must never ACK a non-durable entry — that is half of the majority guarantee.
Recovery Integrity Verdict
replay_wal / persist_recover distinguish a clean log end from corruption and surface a recover_verdict_t:
RECOVER_CLEAN— valid CRC chain to a clean entry boundary, OR an empty first-boot WAL, OR a pre-allocated-zero tail, OR a snapshot sentinel + clean replay.RECOVER_TRUNCATED— replay stopped on structural corruption (CRC chain break, CRC mismatch, short header/payload read).
The verdict is monotonic (init CLEAN; only ever set TRUNCATED; a later clean segment cannot un-set it) and is structural integrity only — "behind the cluster" is a separate concern handled by the freshness/promotion fence. Recovery never silently discards committed entries. Consume via persist_recovery_verdict(p) / persist_recovery_truncated(p) (these are NOT XCE_TESTING-gated — the promotion gate reads them in production).
Durable Floor & Durable-Backed Freshness
On recovery, the member publishes its durable floor = highest intact replayed LSN (the WAL tip) via persist_durable_floor(p). It is assigned, not max'd, so a truncating restart lands at the lower intact tip and never over-advertises. This floor is a durable last-log index, not the commit position.
Election freshness is then durable-backed (formula lives in ha.c/cluster.c, consumed from here):
- A candidate advertises bare
persist_durable_floorfor the LSN axis — it may only claim a position it can durably back. - A voter floor =
max(durable_floor, last_known_committed_lsn)— durable tip with the committed-high-water backstop retained (bug-339).
Result: a truncated/behind node cannot win an election or pass the promotion gate; the most-durable reachable node always can (no livelock).
What Persistence Does NOT Persist
- Never write
committed_lsnto disk. Raft keeps the commit index volatile; durability is about the log, not the commit index. The post-election safe-commit point is re-derived from voter replies (ha_compute_safe_committed_lsn), never from a stale ctrlhello. - LSN 0 is a reserved sentinel — never a real entry; the first append is LSN 1. The commit engine rejects
lsn == 0.
Fail-Loud, Never Self-Exit
When a member cannot reach a committed-consistent state (recovery-truncated/behind, no healthy peer), it enters a loud permanent fence: log a loud operator-visible ERROR (truncation boundary vs cluster HWM), set the sticky recovery_unrecoverable flag, hold non-promotable, stay alive, and keep retrying — it self-heals when a peer returns. It MUST NEVER exit/abort as a designed response. The decision is the pure persist_recover_decision → RECOVER_DECISION_{ELIGIBLE,FENCE,FENCE_UNRECOVERABLE}; the action is persist_recover_loud_fence_if_unrecoverable (no terminal). Authoritative statement: error-handling.md + src/controller/CLAUDE.md "Process Lifecycle".
Testing Durability Deterministically
- Force durability synchronously with
persist_flush(p)(does anfdatasyncand advancesdurable_lsn) when a test needsdurable_lsn ≥ Lbefore exercising a commit. The demand-driven flusher is async, so any test that synchronously expects "covering ACK ⇒ committed advances" must first make the entry durable (mirror this in in-memory fixtures by primingdurable_lsnto the appended tip). XCE_TESTINGseams (e.g.persist_test_set_force_fsync_fail,persist_test_durable_lsn_on_disk) are compiled out of production — verify exclusion withnm.- Gate test runs on the ANSI-stripped
:FAIL:count from the test binary's stdout, not on meson'sFail:0summary (meson aggregates and hides Unity sub-group failures). - Run final durability validation on a real fsync-able filesystem — some sandbox
/tmpmounts mis-handlefdatasync.
Quick Reference
| Concern | Symbol / Site |
|---|---|
| Durable watermark | persist_durable_lsn(p) / persist_t.durable_lsn |
| Durable floor (WAL tip) | persist_durable_floor(p) |
| Commit self-cap | majority_candidate_locked (commit.c) caps self at durable_lsn |
| Group-commit wake | persist_set_durable_cb → on_durable_advance(ctx) |
| Synchronous flush (tests) | persist_flush(p) |
| Recovery verdict | persist_recovery_verdict(p) / persist_recovery_truncated(p) |
| Loud fence (no exit) | persist_recover_loud_fence_if_unrecoverable, recovery_unrecoverable |
| Flush cadence (max batch delay) | --wal-sync-ms, DEFAULT_WAL_SYNC_MS = 10 |