bug-1433: raft_cb_become_leader runs synchronously off peer_ctrl_dispatch_to_pool, which hardcodes VTASK_LIVENESS for every inbound peer-control packet including vote replies. The bug-1431 round-2 fix (flusher_flush_once force-flush in persist_promotion_advance_committed) was reachable from that never-blocks task class -- same bug family as bug-1198. Removed the force-flush; round-3 (durable-capped only) plus the already-shipped bug-1432 immediate-first-heartbeat fix close the visibility-latency gap non-blockingly. TRACKED RESIDUAL: persist_apply_pump_run_sync is still reachable on the same VTASK_LIVENESS path and does bounded pread() -- lower risk (page-cache-hot) but strictly still blocking I/O; not safe to remove outright (reopens bug-1043-family job loss), needs its own follow-up (reclassify peer_ctrl_classify's winning-vote-reply case or dispatch promotion tail onto a fresh VTASK_LIVE_BLOCK vtask). bug-1434: persist_promotion_advance_committed and ha_standby_advance_committed_and_apply raced on persist_t.committed_lsn via an unlocked load-then-conditional-store during the promotion window (before ha->role flips to CTRL_PRIMARY) -- a losing racer could regress committed_lsn. Added persist_committed_lsn_advance_atomic (CAS retry loop, lock-free) and routed both call sites through it. Validated: 19/19 unit test suites, 6-round chaotic soak (0 loss/divergence) + 6-round sequential+leadership-coverage soak, both PASS. Also folds in raft-apply-decoupling and raft-ha-core-safety validate-impl phase bumps to "implemented" and clears config-upgrade-snapshot's now-satisfied entity-bearing-snapshots block.
43 KiB
Design Document — raft-ha-core-safety
Overview
This feature makes repeated and chaotic primary failover on an XCE controller HA cluster provably safe and live. It is a correction-and-verification spec, not a build-from-scratch one: the (term, lsn) commit-identity, conflict-reconcile, Figure-8 commit, and vote-freshness machinery already exist and are validated (raft-log-replication, raft-commit-safety, ha-term-safety), yet the live chaotic soak still produces committed-prefix divergence (LSN 9), standby-rejoin failure, and intermittent election stalls — on this branch and on baseline 21e26a1.
Discovery (task 1.3, live real-fs release soak, independently verified — see research.md) root-caused the residual divergence to cross-term catch-up payload loss, not the term-blind-bypass hypothesis the early analysis proposed (that hypothesis is refuted: the divergent entry carries the correct term and arrives through the reconcile seam). At committed LSN 9 all members agree on term, but the minority member holds an empty payload (PLEN=0, pcrc=0) where the majority holds the real entry (PLEN=3560). The bisect: when a restarted standby catches up an entry whose stored term is below the current leader's term (the bug-1067 cross-term path), the leader-side streamer ha_stream_wal_to_standby (config_repl.c ~2154-2163) silently ships PLEN=0 on a short pread or malloc failure instead of failing loud; the receiver faithfully persists the empty body; and the persistence layer's (term,lsn)-only idempotency guard is payload-blind, so it never detects that an existing-LSN entry now has a divergent payload. The result is a permanently divergent committed prefix that carries the right term but the wrong (empty) data.
The design therefore: (1) collapses every entry-ingest path into the one reconcile seam and makes persistence (term, lsn)-conflict-aware instead of LSN-only-idempotent; (2) closes the snapshot-boundary and standby-reconvergence gaps; (3) guarantees election liveness under churn; and (4) delivers a deterministic chaotic harness that forces each defect RED before its fix and a green 6+ round real-fs soak as the acceptance gate.
Goals
- Committed log prefix is identical across all members under repeated/chaotic failover (zero
walcomparedivergence). - Every entry entering a member's log passes the single
(term, lsn)reconcile seam; no path stamps a non-leader term or silently skips conflict repair. - A restarted node deterministically reconverges to 3-of-3 (committed + applied), and elections always complete under churn.
- Each defect is pinned by a deterministic RED→GREEN test; the spec closes only on a green 6+ round chaotic real-fs release soak with zero divergence and zero committed-job loss.
Non-Goals
- Rebuilding or re-deriving the Raft library consensus core (log replication, commit advance, vote freshness) — adopted as-is.
- Persist-before-ACK durability and the sync barrier — owned by
ha-commit-durability. raft-drive-asyncasync-drive/offload behavior (SlowLane, peer-control dispatch, snapshot/config-push offload, endpoint-quiescence) — out of scope and must not change.- Client-side primary discovery (libxce bug-854) and the overlayfs
fdatasyncenvironment defect (bug-924) — excluded from the acceptance verdict, not fixed here. - Cluster membership resize, multi-region, non-Raft replication.
Boundary Commitments
This Spec Owns
- The single-seam ingest invariant: routing every replicated/catch-up entry through
raft_on_append_entries_requestso logOk +(term,lsn)conflict-truncate + leader-term stamping always run. - Making the WAL persistence idempotency/contiguity guard
(term,lsn)-conflict-aware (detect same-LSN/different-term as a conflict to repair, not a no-op to skip). - Snapshot-boundary
(term,lsn)consistency so post-install AE intake reconciles correctly and no committed entry below the checkpoint can diverge. - Standby reconvergence completeness: post-recovery
log_len/applied sync and catch-up apply completeness before a node reports healthy/3-of-3. - Election liveness under churn: a member that loses its primary always proceeds to a completed election; no permanent stall.
- The deterministic chaotic harness (unit/integration +
xce-test-hasoak extension +walcompareuse) and the acceptance soak gate.
Out of Boundary
- Raft library internals (
src/lib/raft/*) beyond consuming the existing callbacks/seam — no new consensus primitives. - Persist-before-ACK durability mechanism and the commit sync barrier (
ha-commit-durability). raft-drive-asyncdispatch behavior.- libxce client discovery (bug-854); overlayfs env (bug-924).
- Sequential-failover rejoin reconcile already delivered by
ha-convergence-recovery(reused, not re-implemented).
Allowed Dependencies
- Raft library reconcile seam + five callbacks (
raft_on_append_entries_request,on_persist_entry,on_get_entry_term,on_truncate_from,on_commit_advanced). persist_set_term/ per-LSN term index /WAL_LEGACY_TERMmapping (ha-term-safety).ha_rejoin_reconcileintake hook andha_self_restartconvergence primitive (ha-convergence-recovery).walcompare(term, lsn, pcrc)detector;xce-test-hasoak driver;run-ha-soak.shBIN/XT override + non-ASAN release build.- The controller TLA+ spec
raft/raft.tlaand the invariant checklist insrc/controller/CLAUDE.mdas the correctness oracle.
Revalidation Triggers
- Any change to the Raft callback signatures or the reconcile-seam contract → revalidate
ha-term-safety,raft-log-replication,raft-commit-safety. - Any change to WAL header
(term, lsn)layout or the term index → revalidatewalcompareandha-commit-durability. - Any change to the snapshot install boundary contract → revalidate
ha-convergence-recoveryrejoin path. - Closing this gate (green soak) is itself the revalidation trigger that unblocks the
raft-drive-asyncproduction flag-flip.
Retroactive Boundary Crossings (2026-07-09 validate-impl finding)
Two tasks materially crossed the "Out of Boundary" lines above. Both are self-labeled in tasks.md, non-conflicting with the specs that inherited them, and confirmed by a later cross-task validation pass — documented here so a future validate-impl doesn't re-flag them as new findings:
- Task 5.9 shipped a commit-durability fix (
persist_submit_wal_and_commitextraction, hook-rc-clobber job-loss) — territory line 19/36 assigns toha-commit-durability. Landed here as a "down payment" (tasks.md's own words) becauseraft-apply-decouplinghad no tasks.md yet and this spec's Req 8 soak needed zero job-loss to close.raft-apply-decoupling's own task 3.1/3.2 later built the canonical version on the same code path — confirmed non-duplicating successor, not a competing fix. - Task 5.8 shipped an endpoint-refcount/quiescence mechanism — territory line 20 explicitly excludes ("
raft-drive-async... endpoint-quiescence — out of scope and must not change"). In practice it became the shared foundationraft-drive-async's own task 3.2/3.4a cite as the reason their UAF hazards are "closed for real" — both specs' authors acknowledged the dependency in their own tasks.md, making this real, coherent, bidirectional coupling rather than silent scope creep.
Architecture
Existing Architecture Analysis
- The real defect (task 1.3, verified): the cross-term catch-up payload is lost at its source.
ha_stream_wal_to_standby(config_repl.c~2154-2163) reads the entry body withpreadinto amalloc'd buffer; on a short read or allocation failure it frees the buffer and ships the entry withPLEN=0silently (no error, no retry). The receiver persists the empty body, and the(term,lsn)-only persistence guard cannot tell that an existing-LSN entry now carries a divergent (empty) payload. Term and ingest-path are correct; the data is gone. - Single reconcile seam (preserve, do not duplicate): all
(term,lsn)conflict detection/repair lives insideraft_on_append_entries_request(raft.c:646–765). This seam is sound and is preserved as an invariant — but it is not where this bug lives (the divergent entry already passes through it with the correct term). The fix is upstream (streamer payload integrity) and underneath (payload-aware persistence guard). - Layering (must hold): Raft library (pure, callback-driven) ← controller HA adapter (
ha.c,ha_convergence.c,config_repl.c) ← persistence (persist.c, WAL). The adapter calls into the library and implements callbacks; the library never calls the adapter except through callbacks. The fix routes adapter ingest into the library, never around it. - Two distinct truncate contracts (must not be conflated):
- Promotion-truncate (a node becoming leader): clamps at
max(safe_committed_lsn, durable_tip)(ha.c:999–1000, bug-1060) — a fresh leader must not discard its own durable tip. - Follower-AE-conflict-truncate (a follower/standby receiving an entry that conflicts at
(term, lsn)): obeys Raft Log Matching — truncates the conflicting suffix regardless of localdurable_tip. The bug-1060 promotion clamp does not apply here. Rationale: an entry that conflicts with the leader's(term, lsn)was, by Raft's Leader Completeness, never majority-committed, so it is safe — and required — to remove even if the local node had marked it durable/committed. Applying the promotion clamp on this path would block legitimate repair and convert repairable divergence into a permanent fence. - The only entries the follower-conflict path must never cut are those that match the leader (no conflict) — guaranteed by logOk before any
on_truncate_from.
- Promotion-truncate (a node becoming leader): clamps at
- Technical debt addressed: the silent empty-payload ship in the cross-term catch-up streamer and the payload-blind persistence idempotency guard are the primary debt this spec removes. The term-blind ingest branch is retired as defensive hardening (it is a latent hazard, not the cause of the observed divergence).
Architecture Pattern & Boundary Map
graph TB
subgraph Library [Raft Library pure]
AE[raft_on_append_entries_request]
LOGOK[logOk term lsn check]
TRUNC[on_truncate_from]
PERSISTCB[on_persist_entry]
AE --> LOGOK --> TRUNC
LOGOK --> PERSISTCB
end
subgraph Adapter [Controller HA Adapter]
STREAM[Catchup Streamer payload integrity]
INGEST[Unified Entry Ingest]
SNAP[Snapshot Boundary Reconcile]
CONV[Standby Reconvergence]
ELECT[Election Liveness]
end
subgraph Persist [Persistence WAL]
GUARD[term lsn payload conflict guard]
SETTERM[persist_set_term leader term]
TERMIDX[per lsn term index]
end
subgraph Verify [Verification]
HARNESS[Deterministic Chaotic Harness]
WALCMP[walcompare term lsn pcrc]
SOAK[xce-test-ha chaotic soak]
end
STREAM --> INGEST
INGEST --> AE
SNAP --> AE
CONV --> INGEST
PERSISTCB --> SETTERM --> GUARD --> TERMIDX
TRUNC --> GUARD
ELECT -.guarantees.-> AE
HARNESS --> WALCMP
HARNESS --> INGEST
SOAK --> WALCMP
Architecture Integration
- Selected pattern: payload-integrity-at-source + payload-aware-persistence, with the single reconcile seam preserved as an invariant. The cross-term catch-up streamer must never ship a non-empty entry as empty (fail-loud on short
pread/malloc-fail); persistence enforces(term, lsn, payload-crc)conflict semantics so a divergent-payload entry at an existing LSN is detected, not idempotent-skipped. - Boundaries: catch-up payload integrity (Adapter streamer), persistence-conflict (Persist), reconvergence + liveness (Adapter), verification (test-only) are separable seams under one acceptance gate.
- Patterns preserved: single reconcile seam, callback-driven library, truncate-clamp, persist-before-ACK.
- New components rationale: the harness is genuinely new code; the streamer fix hardens a silent-failure path; the persistence change extends an existing guard from
(term,lsn)to(term,lsn,payload-crc). - Steering compliance: async-I/O (no blocking in vtask handlers; file I/O + restart on delaymgr, never callback thread), fail-loud over silent skip, controller-never-self-terminates-on-operational-condition.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Consensus core | src/lib/raft (in-repo) |
reconcile seam + callbacks (consumed) | no library changes beyond callback use |
| Controller HA adapter | C (ha.c, ha_convergence.c, config_repl.c) |
ingest routing, snapshot/rejoin, election | primary change surface |
| Persistence | C (persist.c, WAL segments) |
(term,lsn,payload-crc)-aware guard, term index |
harden guard, no format change |
| Verification | C unit (controller_unit_test), walcompare, xce-test-ha bash |
RED→GREEN + soak gate | new test_ha_core_safety.c; chaotic soak mode |
| Runtime | real filesystem (ext4) release build | acceptance soak | overlayfs excluded (bug-924) |
File Structure Plan
Modified Files
src/controller/config_repl.c— Core fix. Leader-side catch-up streamerha_stream_wal_to_standby(~2154-2163): on a shortpreadormallocfailure when reading an entry body, fail loud (error + retry/abort the stream) instead of silently freeing the buffer and shippingPLEN=0. A non-empty entry must never be transmitted with an empty payload. Disambiguate and handle both send-side-empty (short read/alloc fail here) and any transitive-empty-propagation source uncovered during implementation.src/controller/persist.c— Extend the replicated-write guard (persist_wal_write_replicatedpersist.c:802, idempotency guard at :822/:839) from(term,lsn)-only to(term, lsn, payload-crc)-aware: an entry arriving for an existing LSN whose payload-crc differs (e.g. a real entry over a previously-stored empty one, or vice versa) is a conflict to surface for repair, not an idempotentreturn 0. Never accept an empty payload as the durable form of a known-non-empty entry. Preserve persist-before-ACK.src/controller/ha.c— Receiver-side handling so a recovered/divergent-payload entry is repaired through the reconcile seam (ha_consume_append_entriesha.c:2410-2440 body copy;ha_apply_wal_catchup_entryha.c:2233). Snapshot post-install (ha_handle_snapshot_chunkha.c:2492) establishes the(term,lsn)boundary. Standby reconvergence completion (applied/log_len sync, healthy gating). Election-liveness guarantee on the failover/vote path. Defensive: retire the term-blindha_apply_single_entry(ha.c:2097) direct-persist branch so no future path can bypass the seam.src/controller/ha_convergence.c— Reuse theha_rejoin_reconcileintake hook for chaotic-rejoin reconciliation through the seam; retain theha_self_restartconvergence primitive and once-guard.src/controller/testsuite/meson.build— Registertest_ha_core_safety.c.src/controller/testsuite/walcompare.c— Extend to emit a machine-checkable(lsn → term, pcrc, present)per-member report consumable by the harness verdict (committed-vs-uncommitted classification)..claude/skills/xce-test-ha/run-ha-soak.sh(andSKILL.md) — Chaotic mode + isolated verdict (done, tasks 1.1/1.2). Plus (task 1.3 follow-up): make primary-detection robust to the release build's block-buffered--logfile— switch the soak's primary/convergence detection to a client probe (ctrlstatus) rather than log-grep, so the chaotic soak runs unattended on release binaries.src/controller/log init (only if the client-probe approach is insufficient) — ensure the release build flushes (line-buffers) its--logfileso operator-observable progress is not lost until signal. Prefer the soak-side client-probe fix first; treat a controller log-flush change as a fallback that must not alter production logging semantics.
New Files
src/controller/testsuite/test_ha_core_safety.c— Deterministic RED→GREEN harness: forces the cross-term catch-up payload-loss divergence (a non-empty entry streamed/persisted as empty at an existing committed LSN), snapshot-boundary divergence, standby reconvergence-failure, and election-stall paths using existing gate/probe seams (pattern:test_ha_dispatch_integration.c); no sleeps.
Dependency direction (enforced):
persist.c(term/lsn storage) ←ha.c/config_repl.c/ha_convergence.c(adapter ingest/election) → Raft library seam. Tests depend on all; production never depends on tests.
System Flows
Cross-term catch-up payload integrity (the fix)
sequenceDiagram
participant L as Leader streamer
participant S as Standby ingest
participant Seam as raft_on_append_entries_request
participant P as persist term lsn payload guard
L->>L: read entry body pread into malloc buffer
alt short read or malloc fail
L->>L: FAIL LOUD error retry or abort stream
Note over L: never ship a non empty entry as PLEN 0
else full body
L->>S: stream entry at LSN N with full payload and term
end
S->>Seam: route through reconcile seam
Seam->>P: on_persist_entry N term payload
alt existing LSN payload crc differs
P->>P: conflict surface for repair not idempotent skip
else identical term and payload crc
P->>P: idempotent no op safe
end
P-->>S: WAL holds full payload at N
Note over S,P: walcompare term and pcrc identical across members
Key decisions: the streamer fails loud on a short pread/malloc failure instead of silently shipping PLEN=0; the persistence guard compares (term, lsn, payload-crc) so an empty-over-real (or real-over-empty) write at an existing LSN is a conflict to repair, not an idempotent skip. The single reconcile seam is preserved; truncation (when a genuine (term,lsn) conflict exists) never cuts below max(safe_committed_lsn, durable_tip) on the promotion path and follows the follower-conflict contract on the receive path.
Standby reconvergence gating
stateDiagram-v2
[*] --> Rejoining
Rejoining --> CatchingUp: route entries through seam
CatchingUp --> AppliedSync: log tip reached at match level
AppliedSync --> Healthy: applied_lsn and log_len equal leader committed
AppliedSync --> CatchingUp: apply gap remains
Healthy --> [*]: report 3 of 3
A node is reported healthy/3-of-3 only when committed and applied state match the leader — not at match-index alone (closes the bug-1042 reconvergence gap).
Requirements Traceability
| Requirement | Summary | Components | Key Contracts | Flows |
|---|---|---|---|---|
| 1.1–1.4 | Committed prefix identical; no divergent entry at a position; divergent treated non-authoritative; TLA+ State-Machine/Commit safety | Catch-up Payload Integrity; Persist Conflict Guard | streamer fail-loud; (term,lsn,payload-crc) guard |
Payload integrity |
| 2.1–2.4 | Entry carries faithful identity+payload; never empty-over-real at a committed LSN; identity distinguishes conflicts; survives restart | Persist Conflict Guard; Catch-up Payload Integrity | (term,lsn,payload-crc) guard; persist_set_term; term index |
Payload integrity |
| 3.1–3.4 | Detect conflict; repair to majority prefix; surface unrecoverable; never cut majority-committed | Unified Entry Ingest; Snapshot Boundary | on_truncate_from; follower-conflict truncate contract (logOk-gated) |
Divergence repair |
| 4.1–4.3 | At most one committing primary/term; step-down on higher term; Election-Safety/Leader-Completeness | Election Liveness (step-down) | vote-once; pre-vote; vote freshness | — |
| 5.1–5.4 | Rejoin → 3-of-3 in bound; applied+log_len sync; catch-up completeness; not converged until applied matches | Standby Reconvergence | healthy-gating contract | Reconvergence gating |
| 6.1–6.3 | Elect higher-term primary in bound; keep electing under churn; retry on failed round | Election Liveness | election-restart guarantee | — |
| 7.1–7.5 | Force divergence/reconverge/stall RED on baseline; GREEN after; extend soak + unit/integration | Deterministic Chaotic Harness | RED→GREEN gate | all |
| 8.1–8.4 | 6+ round chaotic real-fs soak: zero divergence, zero job loss, reconverge+elect each round; gate for flag-flip | Acceptance Soak Gate | soak verdict (bug-854/924 isolated) | all |
Components and Interfaces
| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| Catch-up Payload Integrity | Adapter | Stream a non-empty entry never as empty; fail-loud on short read/alloc-fail | 1,2,3 | catch-up streamer (P0); Persist guard (P0) | Service, State |
| Persist Conflict Guard | Persist | (term,lsn,payload-crc)-conflict-aware write; never accept empty-over-real |
1,2,3 | term index (P0); persist_set_term (P0) |
State |
| Unified Entry Ingest | Adapter | Route every entry through the one reconcile seam; retire term-blind bypass (defensive) | 1,2,3 | Raft seam (P0); Persist guard (P0) | Service, State |
| Snapshot Boundary Reconcile | Adapter | Establish (term,lsn) at checkpoint; post-install reconcile |
3,5 | snapshot install (P0); seam (P1) | State |
| Standby Reconvergence | Adapter | Gate healthy/3-of-3 on committed+applied match | 5 | catch-up apply (P0); ha_rejoin_reconcile (P1) |
Service, State |
| Election Liveness | Adapter | Always complete an election under churn; step-down on higher term | 4,6 | vote/election path (P0) | Service |
| Deterministic Chaotic Harness | Verify | Force each defect RED→GREEN; no sleeps | 7 | gate/probe seams (P0); walcompare (P0) |
Batch |
| Acceptance Soak Gate | Verify | 6+ round chaotic real-fs soak verdict | 8 | xce-test-ha; release build |
Batch |
Adapter
Catch-up Payload Integrity
| Field | Detail |
|---|---|
| Intent | The cross-term catch-up streamer must transmit every non-empty entry with its full payload, or fail loud — never silently ship PLEN=0. |
| Requirements | 1.1, 1.2, 2.1, 2.3, 3.1 |
Responsibilities & Constraints
- In
ha_stream_wal_to_standby(config_repl.c~2154-2163): when reading an entry body (preadinto amalloc'd buffer), a short read or allocation failure must be handled as an error (log + retry/abort the stream), not by freeing the buffer and sending the entry with an empty payload. - Disambiguate the two possible sources the reviewer flagged: (a) send-side empty — the short-read/alloc-fail here; (b) transitive-empty propagation — the streamed source WAL already holds an empty body (itself produced by an earlier instance of this defect). The fix must close (a) directly and, if (b) is observed, trace it to its own streamer instance rather than masking it.
- Cross-term entries (stored term < leader current term, the bug-1067 path) are the trigger seen live — ensure they are read and shipped identically to same-term entries.
Dependencies: Inbound: walAck catch-up continuation (P0). Outbound: Persist Conflict Guard (P0) on the receiver; the reconcile seam (P1).
Contracts: Service [x] / State [x]
- Precondition: the source WAL holds a non-empty body for the entry being streamed.
- Postcondition: the receiver persists the same
(term, lsn, payload-crc)the leader holds; a transmit failure is surfaced, never silently degraded to an empty entry. - Invariant:
walcomparereports identical(term, pcrc)for the streamed LSN across leader and standby.
Implementation Notes
- Validation: the RED test reproduces the live signature — a cross-term catch-up of an entry whose body read short/fails must NOT result in a persisted
PLEN=0entry at a committed LSN; instead the stream errors and retries. Pin send-side vs transitive via an instrumentedpayload_lencapture at send and aton_persist_entry. - Risks: failing loud on the stream must not wedge catch-up permanently — pair the error with a bounded retry so a transient short read recovers, while a persistent failure is surfaced (fail-loud over silent skip; controller never self-terminates).
Unified Entry Ingest
| Field | Detail |
|---|---|
| Intent | Every replicated/catch-up entry passes raft_on_append_entries_request (logOk + (term,lsn) conflict + leader-term stamp). The term-blind direct-persist branch is closed by reachability, not by removal — task 6.2 traced every production sender and found it unreachable with real data; documented SAFE-BY-GUARD rather than structurally retired (see task 6.2's Implementation Note below). |
| Requirements | 1.2, 2.3, 3.1, 3.2, 3.4 |
Responsibilities & Constraints
- Defensive: the
entry->term == 0direct-persist branch (ha_apply_single_entry) is NOT structurally removed (task 6.2 decision). Every real sender (repl_broadcastfor live replication,ha_stream_wal_to_standbyfor catch-up) always suppliesn_entries>=1with a non-zero term, so this branch is unreachable with production data; the sole function that could ever drive it (repl_send_append_entries) is invoked only by the raft library'sraft_send_append_entries, which has zero production callers today. It remains documented and guarded inha.c, not retired. (Not the cause of the observed divergence, but a latent bypass hazard — now shown to be dormant, not live.) - Conflict repair on this (follower/standby) path uses the follower-AE-conflict-truncate contract (truncate the conflicting suffix per Raft Log Matching) — not the promotion clamp. logOk gates the truncate, so only genuinely-conflicting (never-majority-committed) entries are cut. The promotion clamp applies only to the leader-promotion path, which this component does not touch.
- File I/O / truncation / restart never on a callback or iomgr thread — defer to delaymgr (existing pattern).
Dependencies: Inbound: catch-up/replication/rejoin call sites (P0). Outbound: Raft seam (P0); Persist Conflict Guard (P0).
Contracts: State [x] / Service [x]
State Management
- State model: per-member WAL log indexed by LSN, each entry carrying
(term, payload); librarylog_len/commit_indexmirror the WAL. - Consistency: post-ingest,
on_get_entry_term(N)equals the leader's term for N across all members;walcomparereports identical(term, pcrc)on the committed prefix. - Concurrency: ingest may run on iomgr; deferred truncate/persist follow the existing delaymgr one-shot pattern.
Implementation Notes
- Integration: reuse
ha_apply_term_entry_via_library/ha_consume_append_entriesfor every reachable ingest path. - Validation (task 6.2, closing task 2.4's deferral): rather than retiring
ha_apply_single_entry's direct-persist path, task 6.2 traced every production sender of awal_catchup_entry_tmessage and found the term-blind branches (the batch-modesub->term==0fallthrough and the unconditionalentry->n_entries==0single-entry mode inha_apply_wal_catchup_entry) unreachable with real data:repl_broadcastalways sendsn_entries==1with the real stamped term;ha_stream_wal_to_standbyalways batchesn_entries>=1and sources each sub-entry's term viapersist_entry_term, whose legacy-zero rule maps a stored term of 0 toWAL_LEGACY_TERMfor any present LSN (never 0). The only function that could emit the term-blind shape,repl_send_append_entries, is driven solely by the raft library'sraft_send_append_entries, which has zero production call sites (only the pure library simulator and anXCE_TESTINGtest helper call it). Decision: document safe-by-guard, do not structurally retire — retiring would mean building a term-0-safe seam variant for a path with no live trigger, which is speculative work the evidence does not warrant. The disposition and reachability trace are recorded as comments atha_apply_single_entryand both term-blind branches inha.c, and pinned bytest_2_3_termblind_route_payload_guarded,test_2_3_unrepairable_catchup_raises_nonpromotable_fence, andtest_6_2_real_senders_never_produce_termblind_shape(test_ha_core_safety.c). - Risks: the safety boundary on this path is logOk, not a durable-tip clamp — only entries that fail the
(term, lsn)match are truncated, and a failed match proves the entry was never majority-committed (Leader Completeness). A unit test must assert that an entry which matches the leader is never truncated, and that a conflicting entry below localdurable_tipis truncated.
Standby Reconvergence
| Field | Detail |
|---|---|
| Intent | A recovered standby — including a former primary holding a divergent uncommitted tail above the new leader's committed point — reconverges to follower (truncates the divergent tail, catches up) and is reported healthy/3-of-3 only after committed and applied state match the leader. |
| Requirements | 5.1, 5.2, 5.3, 5.4 |
Responsibilities & Constraints:
- Divergent-tail reconvergence (the live-stall fix, task 1.3/Phase-4 investigation): when a standby recovers with an uncommitted tail whose entries conflict with the leader's log (the former-primary
current_lsn > leader_committedshape — e.g. ctrl-2 holding 12-15 at an old term while the leader committed 11), the AE log-matching backtrack must reach the divergence point and the follower must truncate the conflicting suffix (follower-AE-conflict-truncate contract) and accept the leader's entries — the same way a short-suffix standby already reconverges (ae_reject_logmismatch×N →ae_already_have). Determine and remove whatever prevents a longer/former-primary divergent tail from reachingae_already_have(candidate: the leader'snext_indexbacktrack does not cross the divergence before the control channel drops; or the recovered tip is advertised in a way that stalls the backtrack). - Post-recovery
log_len/applied-lsn sync; complete outstanding catch-up apply before "healthy"; bounded recovery window.
Dependencies: Inbound: rejoin path (P0). Outbound: Unified Entry Ingest (P0); ha_rejoin_reconcile hook (P1); the leader-side AE streamer next_index backtrack (P0).
Contracts: State [x] / Service [x]
- Postcondition: a recovered standby with a divergent uncommitted tail truncates it and reaches
applied_lsn == log_len == leader_committed_lsn; reported converged only then, otherwiseCatchingUp.
Implementation Notes
- Integration: reuse the
ha-convergence-recoveryrejoin hook; ensure the AE backtrack + follower-conflict-truncate path actually fires for a former-primary divergent tail, not only a 1-2 entry suffix. - Validation: RED integration test — construct a recovered standby with a multi-entry divergent uncommitted tail above the leader's committed point and assert it reconverges to
ae_already_have(no permanentae_reject_logmismatchloop) and is not reported converged until applied state matches.
Election Liveness
| Field | Detail |
|---|---|
| Intent | A member that loses its primary always proceeds to a completed election; at most one committing primary per term. |
| Requirements | 4.1, 4.2, 4.3, 6.1, 6.2, 6.3 |
Responsibilities & Constraints:
- On genuine primary loss with quorum reachable, elect a higher-term primary within the failover window; restart a failed election round; step down on observing a higher/equal-term leader. No permanent stall under repeated rapid rounds.
- Spurious-failover suppression (the live-stall fix, task 1.3/Phase-5 investigation): a transient primary control-channel RDHUP must not launch an unwinnable PreVote-failover storm while a current-term primary is actually alive and reachable by the quorum. The observed stall: a recovered standby whose channel to the live primary flaps (~15s) treats each RDHUP as "primary died" →
ha_begin_failover→ PreVote that times out (peers see the primary alive, deny/ignore) → reconnect → repeat forever, so the node never rejoins. The fix: on a control-channel RDHUP, attempt reconnect + catch-up first, and/or back off failover when a node's own PreVotes repeatedly fail to reach a quorum because a current-term leader exists — only escalate to a real failover when the primary is genuinely unreachable to the quorum. The election retry already works (R6); the defect is retrying an unwinnable election instead of rejoining the live primary.
Dependencies: Inbound: failover detection / ha_on_primary_ctrl_fd_rdhup (P0). Outbound: Raft vote/election + raft_cb_vote_eligible (P0); peer reconnect path (P0).
Contracts: Service [x]
- Invariant: never two committing primaries in one term (Election Safety); a node without a reachable primary keeps initiating elections (liveness); a node WITH a reachable current-term primary rejoins it rather than launching repeated unwinnable failovers.
Implementation Notes
- Validation (liveness, R6): RED integration test forces repeated rapid kill/restart rounds and asserts a higher-term
CTRL_PRIMARYappears each round; a scripted single-round failure asserts a follow-on election starts. - Validation (spurious-failover, the live stall): RED test/soak asserts a recovered standby whose control channel flaps while a current-term primary is alive does NOT enter an unbounded
ha_begin_failover/PreVote timed outloop — it reconnects and rejoins as follower. - Validation (safety, R4 — two-leaders-per-term): an explicit Election Safety assertion runs in the chaotic harness and soak — for every observed term, at most one member ever reaches committing-primary state. This is a falsifiable verification, not an assertion of correctness: it maps to
raft/raft.tlaElection Safety and must hold under overlapping kills. If it fails, the design's "library already enforces single-leader" assumption is disproven and a concrete guard (e.g. tighten the pre-vote/vote-once window at the adapter promotion gate) becomes required work rather than verification. - Risks: over-eager step-down could itself cause a stall — step-down must be paired with election restart. The harness must distinguish a liveness failure (no primary) from a safety failure (two primaries) so a fix for one does not regress the other.
Persist
Persist Conflict Guard
| Field | Detail |
|---|---|
| Intent | (term, lsn, payload-crc)-aware write: identical (term, payload-crc) at an LSN is an idempotent no-op; a differing payload-crc (or term) at an existing LSN is a conflict surfaced for repair — never a silent return 0. An empty payload is never accepted as the durable form of a known-non-empty entry. |
| Requirements | 1.1, 1.2, 2.1, 2.2, 2.4, 3.1, 3.4 |
Responsibilities & Constraints: extend the LSN-only idempotency check (persist.c:822) to compare (term, payload-crc); preserve persist-before-ACK; preserve the term index and WAL_LEGACY_TERM mapping.
Dependencies: Inbound: on_persist_entry/on_truncate_from (P0). Outbound: term index record_entry_term/persist_entry_term (P0).
Contracts: State [x]
- Precondition: caller has passed the entry through the seam (leader term known via
persist_set_term; logOk already decided match vs conflict). - Postcondition: WAL at LSN N holds exactly the leader's
(term, payload); no silent retention of a stale-term or empty-payload entry. - Invariant: never write
term == 0for a live-replicated entry, and never let aPLEN=0write overwrite or shadow a known-non-empty entry at the same LSN. On the seam-driven conflict path the guard honorson_truncate_from(follower-AE-conflict contract) — it does not re-apply the promotion durable-tip clamp, which belongs to the leader-promotion path only. The guard never initiates a truncate on its own; it only executes the seam's decision.
Implementation Notes
- Integration: the guard distinguishes "already have identical entry (same term + payload-crc)" from "have a different-payload entry at this LSN"; the latter is surfaced for repair instead of
return 0. - Validation: unit test writes an empty-payload entry at LSN N, then the correct full-payload entry, and asserts the WAL ends with the full payload (no silent idempotent skip); and the reverse (full then empty) never degrades a stored entry.
- Risks: mis-tagging an identical
(term, payload-crc)entry as a conflict would cause needless work — the guard compares both term and payload-crc and treats a true match as idempotent.
Verify
Deterministic Chaotic Harness
| Field | Detail |
|---|---|
| Intent | Force each defect deterministically; RED on baseline, GREEN after fix; no sleeps. |
| Requirements | 7.1, 7.2, 7.3, 7.4, 7.5 |
Contracts: Batch [x]
- Trigger: unit/integration run (
controller_unit_test) +xce-test-hachaotic soak. - Input: injected divergent
(term,lsn)entry; injected apply gap; scripted overlapping kills inside election windows. - Output: pass/fail per path;
walcompare(term,lsn,pcrc)report. - Idempotency & recovery: deterministic gate/probe seams (pattern
test_ha_dispatch_integration.c); teardown drains pending tasks (avoid the known teardown-race UAF class, bug-1224).
Hypothesis-validation discipline (mandatory):
- The live-repro gate (task 1.3) has already run and refuted the term-blind hypothesis (verified): the real defect is cross-term catch-up payload loss (
research.md§"Task 1.3"). The RED unit/integration cases must therefore reproduce the observed signature — a non-empty entry persisted asPLEN=0at a committed LSN under cross-term catch-up — not the synthetic term-conflict the earlier draft assumed. - A green synthetic unit test over a still-red soak is not a pass: R1/R8 close only when the chaotic real-fs soak (task 7.2) reports zero
walcomparedivergence with the streamer + guard fixes in place. - Contingency: if the soak still diverges after the streamer payload-integrity fix and the payload-aware guard land, treat it as a new finding — classify via
walcompare (term,lsn,pcrc), record inresearch.md, and add the next fix task; do not mark R1/R8 done on the strength of green unit tests alone. (The reviewer flagged one still-open sub-question: send-side-empty vs transitive-empty propagation — disambiguate during the streamer fix.)
Acceptance Soak Gate
| Field | Detail |
|---|---|
| Intent | 6+ round chaotic failover on real fs (release build): zero divergence, zero job loss, reconverge+elect each round. |
| Requirements | 8.1, 8.2, 8.3, 8.4 |
Contracts: Batch [x]
- Trigger:
run-ha-soak.shchaotic mode, real-fs (ext4) release binaries. - Output: verdict that isolates bug-854 (client discovery) and bug-924 (overlayfs) from the divergence/job-loss result.
- Idempotency & recovery: verdict counts
walcomparedivergences and committed-job loss; gate passes only at zero across ≥6 rounds. This green result is the trigger that unblocks theraft-drive-asyncproduction flag-flip.
Error Handling
Error Strategy
- Conflict at an LSN (same LSN, different term) on the follower/standby path: logOk fails ⇒ seam truncates the conflicting suffix (follower-AE-conflict contract, no promotion clamp) and repersists the leader's
(term, payload); never a silent no-op (fail-loud over silent skip). This is the normal, recoverable case — a conflicting entry was never majority-committed, so cutting it loses no committed data. - Genuinely unrecoverable divergence: only when a node cannot obtain the majority-agreed prefix at all (e.g. the leader's log no longer covers the needed range and no snapshot can backfill it) — surface a loud, non-promotable fence and keep retrying; the controller does not self-terminate on an operational condition (steering invariant). Operator-visible WARN/ERROR with both LSNs/terms. Note: a local entry conflicting below the node's own
durable_tipis not in this category — it is repairable via truncate, because the conflict proves it was never committed cluster-wide. - Reconvergence apply gap: remain
CatchingUp, keep streaming via the seam; never report healthy with an open gap. - Election round failure: start a further election; never park without a primary while quorum is reachable.
Monitoring
walcomparedivergence count (soak verdict), per-edge quorum transition WARN (existing), election round/term progression logs, reconvergence applied/log_len deltas. Each defect path emits an operator-observable line so the soak verdict is explainable.
Testing Strategy
Unit Tests (test_ha_core_safety.c, controller_unit_test)
- Streamer payload integrity: a cross-term catch-up whose entry-body read returns short /
mallocfails must NOT persist aPLEN=0entry at a committed LSN — the stream errors and retries instead (1.1, 2.1, 3.1). - Payload-aware guard: an empty-payload write at existing LSN N followed by the correct full-payload entry ⇒ WAL ends with the full payload (guard did not silently idempotent-skip); the reverse (full then empty) never degrades the stored entry (1.1, 1.2, 2.1, 2.2).
- Defensive term-blind retirement: a
term==0-shaped catch-up entry routes through the seam and is reconciled (no direct-persist bypass) (1.2, 3.2). - Follower-conflict truncate contract: a conflicting entry below local
durable_tipis truncated and repaired (the promotion clamp does not block it); a matching entry is never truncated (3.2, 3.4). - Reconvergence gate: injected apply gap ⇒ node not reported converged until closed (5.2, 5.3, 5.4).
Integration Tests
- Snapshot post-install reconcile: a divergent pre-snapshot entry is reconciled against the checkpoint
(term,lsn)(3.1, 5.1). - Election liveness under churn: repeated rapid kill/restart ⇒ higher-term
CTRL_PRIMARYeach round; scripted failed round ⇒ follow-on election (6.1, 6.2, 6.3). - Election Safety (two-leaders-per-term): across overlapping kills, assert at most one member ever reaches committing-primary per term — falsifiable check mapped to
raft/raft.tlaElection Safety; failure promotes a concrete adapter-gate guard to required work (4.1, 4.3).
E2E / Soak (xce-test-ha chaotic mode, real fs, release)
- Live-repro first: the chaotic soak must first reproduce the actual
walcomparedivergence (RED) and bisect it to a seam before synthetic unit tests are accepted as representative (7.1). - ≥6 rounds chaotic failover: zero
walcomparedivergence (8.1); zero committed-job loss (8.2); 3-of-3 reconverge + new primary each round (8.3); verdict isolates bug-854/bug-924 (8.4).
RED-first ordering
- Every implementation task is preceded by its failing test on the unfixed baseline (project TDD steering). The live-divergence repro (7.1) and harness/RED tests land before the ingest/persist/election fixes so each fix is pinned to a
walcompare/invariant violation (7.1–7.4). A green synthetic unit test over a still-red soak does not satisfy R1/R8.
Security Considerations
- No new external surface; all changes are intra-cluster consensus/persistence. The single-seam invariant strengthens integrity (no unvalidated entry enters a member's committed log). Fenced unrecoverable-divergence prevents serving divergent committed state. No secrets, auth, or input-boundary changes.