Investigation & fixes (failover committed-job loss): - root-cause bug-851 on an owned 3-node cluster: chaotic failover causes silent committed-prefix divergence (WAL hdr.term always 0; two leaders per term; no (term,lsn) reconcile). Evidence in repro-851/ + INVESTIGATION. - live-validated fixes 848/849/850 (recovery-on-promotion, advertise real applied_lsn, persist-layer contiguity guard); add waldump WAL inspector. Specs (strict raft.tla conformance — library owns the log): - TLA-gap-analysis.md: impl vs raft/raft.tla, gaps G1-G6. - raft-log-replication, raft-commit-safety, raft-sim-conformance: extend src/lib/raft to own log/commit/verification via callbacks (specs generated, cross-spec review passed). - ha-term-safety: controller-side adapter + config marker (R1-R7); design done. - config-upgrade-snapshot: R8 descoped here (deps entity-bearing-snapshots, bug-857). - roadmap + buglog (851/854/857) + .wolf knowledge updated.
11 KiB
Brief: ha-commit-durability
Problem
A 3-node HA controller failover test permanently lost committed jobs. The client submitted 10 jobs across several forced primary kills; the 7 earliest (JobIds 0-0-1781708993..1781709035, WAL LSN ≤20) — which had been replicated and majority-ACKed, and survived the first failover — were gone by the end, while only the 3 latest survived. Losing an acknowledged, committed job is a correctness (data-loss) failure, the most severe class for this system.
Current State
The HA consistency machinery is implemented (commit engine, WAL replication, convergence/truncate-on-demotion, single-leader own-term gate, PreVote, vote-freshness bug-339). But "committed" does not imply "durable on a majority":
- WAL fsync is asynchronous — a timer thread (
persist.c:173 fsync_worker) fdatasyncs everywal_sync_ms.persist_appenddoes not fsync before returning; the standby does not fsync before sending itsWalAck. - The commit engine advances
committed_lsnon majority ACK (commit.c:303 commit_on_ack,repl_wait_majority_ack). An ACK only proves the entry reached the OS page cache — it can be durable on zero disks at the moment it is declared committed. - Recovery silently truncates to nothing. When killed nodes restart,
persist_recoverhits a CRC chain break at a zeroed/never-fsync'd region (ctrl-3.log:1597prev_crc=00000000at offset 40) and stops replay at ~0 entries — but the node still becomes eligible. - A hollow node wins and overwrites the good timeline. With durability lost on the killed nodes and failover quorum collapsed to
required=1, the freshness gate cannot fence the stale candidate. ctrl-3 became PRIMARY term=4 advertisingcommitted_lsn=21over an empty job table (ctrl-3.log:1612); the node that did hold the data (ctrl-1, applied_lsn=22) converged down to it (ctrl-3.log:1618). LSN ≤20 discarded forever.
This violates the Leader-Completeness assumption documented in src/controller/CLAUDE.md ("committed entries are on a majority" presumes majority-ACK ⇒ durable-on-a-majority) and the TLA+ mapping log[i] = WAL on disk.
Desired Outcome
- An operation reported committed is durable on a majority of nodes before it is declared committed — surviving simultaneous crash + restart of any minority.
- A node that cannot recover its WAL to a valid committed tip is fenced from leadership (fail-loud), never silently truncating to zero and then leading; the cluster never converges onto a timeline below its known committed high-water mark.
Approach
Two coupled requirements (chosen scope: Durability + promotion fence):
- R1 Durable commit (fsync-before-ACK): the standby
fdatasyncs the appended WAL bytes before emittingWalAck; the primary counts its own vote toward majority only after its localfdatasync.committed_lsnadvances only on a durable majority. Preserve throughput via group-commit / batched fdatasync (amortize the sync across pending appends) rather than one sync per entry. Keepwal_sync_msasync path only for non-committing/best-effort writes, if any remain. - R2 Recovery & promotion fence: a WAL CRC/replay failure that prevents reconstructing a contiguous committed tip makes the node ineligible to become primary until it has caught up from a healthy peer (FATAL or fence, never silent truncate-to-zero-then-lead). A node must not win election or self-promote advertising a
committed_lsnbelow the cluster's known committed high-water mark, even under degraded single-voter quorum.
Honor the user's fail-loud preference: surface the real durability/recovery failure rather than masking it.
Scope
- In: fsync-before-ACK on the standby apply path and primary self-count; durable-majority gating of
committed_lsn; group-commit batching to bound the latency cost; fencing an un-recovered/corrupt-WAL node from promotion; preventing a sub-high-water-mark leader from being elected under degraded quorum; unit tests for each; the 3-node live E2E that reproduces this incident (kill-primary-under-load, assert zero committed-job loss). - Out: WAL wire format / ACK message shape (
ha-wal-replication); per-LSN majority math (ha-commit-engine— consumed, the durability precondition of its ACK input is what changes); demotion truncate/self-restart and rejoin reconciliation of an intact log (ha-convergence-recovery); promotion no-op / own-term gate (ha-single-leader-role); raw vote-grant arithmetic (raft-library— this spec adds the durability+high-water precondition, not new vote math); entity-bearing snapshots (roadmap); client-side failover (libxce-failover).
Boundary Candidates
- Durable-commit seam: standby
fdatasync-before-WalAck+ primary self-fdatasync before majority self-count (the "ACK ⇒ durable" guarantee). - Promotion-eligibility seam: recovery-integrity + committed-high-water check gating
raft_cb_become_leader/ PreVote eligibility.
Out of Boundary
- Tuning the async
fsync_workercadence as a "fix" (it is the defect for committing writes, not a knob to turn). - Changing how a majority is counted (commit-engine) or how votes are granted (raft-library) beyond adding the durability/high-water precondition.
- Worker-reported fact resync across restart (
worker-rejoin-resync, roadmap backstop).
Upstream / Downstream
- Upstream:
ha-commit-engine(committed_lsn semantics it now gates on durable ACKs),ha-wal-replication(ACK/catch-up machinery this makes durability-correct),persist.cfsync infrastructure,ha-prevote+ vote-freshness (bug-339) eligibility path it strengthens. - Downstream:
ha-convergence-recoveryE2E (its single-outcome guarantee depends on this),component-failover-adoptionand any real failover deployment.
Existing Spec Touchpoints
- Extends/strengthens:
ha-wal-replication(durability precondition on ACK),ha-commit-engine(durable-majority),ha-convergence-recovery(assumes intact persisted committed state — this spec makes that assumption true). - Adjacent (avoid overlapping):
ha-single-leader-role,raft-library,ha-prevote.
Constraints
- C / gnu17,
src/controller/persist.c,commit.c,repl.c,ha.c,cluster.c; TDD mandatory (tests intestsuite/before C changes). - Async-I/O rule: never block the iomgr thread — fdatasync must not stall heartbeat/vote handling; use the dedicated persist/fsync thread + group-commit and signal the commit waiter on completion.
- Must verify against
raft/raft.tlaand thesrc/controller/CLAUDE.mdProtocol Verification Checklist (commitIndex monotonic; leader holds all committed entries). - Live E2E needs
/xce-deploy-ha(cluster at/tmp/xce-ha-deploy); reproduction harness =xce_testdriving jobsub + primary kills. An automated soak reproduction now exists atsrc/tools/test-commit-durability.sh(rolling SIGKILL+restart under load, asserts every acked job survives). - Performance: durable-majority adds sync latency to the submit path — group-commit is required, not optional.
Reproduction Findings (2026-06-17, from building the E2E)
- bug-661 is a RACE, not a deterministic failure.
wal_sync_msis hardcoded toDEFAULT_WAL_SYNC_MS = 10ms (config.h:86) with no CLI/env/config override. The committed-but-not-fsync'd window is ~10ms, so a single-node SIGKILL+restart only loses a committed job when the kill lands inside that window AND the truncated node re-leads before catch-up repairs its WAL. The soak reproduced real loss once (4 committed jobs) but passed an 8-round run clean — confirming the rarity. - Required test seam (NEW spec item): a configurable fsync interval (e.g.
--wal-sync-ms, plumbed intopersist_config_t.wal_sync_ms). Setting it to several seconds makes every committed entry provably un-fsync'd at kill time, turning the racy soak into a deterministic RED. This seam is also the natural way to unit-test fsync-before-ACK. None exists today — add it as part of Requirement 1's testability (cf. the convergence spec's XCE_TESTING restart seam). - Acceptance asymmetry: a soak FAIL proves the bug; a soak PASS does not prove durability (the race may simply not have fired). After the fix, the soak must pass deterministically regardless of timing — that, plus the deterministic
--wal-sync-mstest, is the real acceptance bar. - Amplifier confirmed: loss needs a killed+restarted node to re-lead with a truncated WAL before replication catch-up repairs it. The fix must therefore also fence a node that has not caught up to the committed high-water mark from leading (Requirement 2).
Design Decision — durability mechanism (2026-06-17, with user)
Invariant (user-stated, authoritative): when the controller replies ok to a client, the operation's WAL entry MUST already be persisted (fdatasync'd) on a fault-tolerant majority — self + ⌊N/2⌋ standbys.
Exact defect located: commit.c:majority_candidate_locked counts the primary's own contribution as cap = highest_appended_lsn(p) — every appended (page-cache) entry, not every fdatasync'd one. On 3 nodes a commit needs self + 1 standby; the standby ACK is durable (persist_wal_write_replicated:515 fdatasyncs before replying) but the primary's copy is not (it relies on the fsync_worker timer). So a "committed" entry is durable on exactly 1 node → zero durability fault-tolerance. That is bug-661.
Chosen direction (user): synchronous persistence — eliminate fsync_worker as the durability mechanism.
- The submit path already blocks off the iomgr thread waiting for majority replication (
job.c:568-569;persist_commit_barrier → commit_submit(COMMIT_MODE_WAIT)), so adding the primary's ownfdatasyncon that path is liveness-safe — it does not stall heartbeats. - Minimal correctness change: (1) primary
fdatasyncs its entry before its self-contribution covers that LSN (symmetric with the standby path atpersist.c:515); (2)commit.cself-contribution uses adurable_lsn(highest fdatasync'd), nothighest_appended_lsn; (3) reply stays gated on the commit barrier → reply-ok ⇒ durable-majority. - Throughput: per-entry fdatasync. The dominant amplifier (a job array fanning out to N entries → N fsyncs) is removed by the roadmap item job-array-single-wal-record (one WAL record per array → one fsync). Cross-reference that item; this spec assumes it lands or is acceptable interim.
- Group-commit IS in scope (revised 2026-06-18 at design-review). Initially deferred, but
/kiro-validate-designfound the highest-volume WAL path isWAL_MODE_RETAINfacts (job start/finish/fail/rusage) — not array-collapsible, not covered by job-array-single-record — so a pure per-entry synchronous design penalizes the dominant writes. Decision (with user): repurposefsync_workerinto a demand-driven group-commit flusher (batchfdatasync, advancedurable_lsn, wake commit-eval) as part of this spec. The roadmap "WAL group-commit batching" item is absorbed here. - Open for design: whether to delete the
fsync_workerthread entirely or retain a minimal best-effort flush for any non-client-acked / local-only writes (e.g. checkpoint markers already fsync inline; auditwal_classifyfor any write that gates no reply). Confirm no client-acked op bypasses the commit barrier.