Mermaid treats ';' as a statement separator, so 'Note over L: no callback; barrier untouched' split mid-note and failed with a Parse error before 'end'. Replaced the semicolon with a comma. Validated PARSE_OK via mermaid@11 parse(). The other ';' (config-bytes flowchart line) is inside a double-quoted node label, which mermaid treats as a literal — valid, left unchanged.
44 KiB
Design Document
Overview
Purpose: ha-term-safety is the controller-side integration + config + live-regression umbrella for the Raft TLA+ Conformance library series. It wires the XCE controller as a thin callback adapter to the protocol that raft-log-replication, raft-commit-safety, and raft-sim-conformance implement in src/lib/raft/raft.c, closing bug-851 (silent committed-prefix divergence under chaotic failover) without re-implementing any Raft protocol logic in the controller. (The related configuration-upgrade materialized-state divergence, bug-857, is descoped to config-upgrade-snapshot.)
Users: The XCE HA controller cluster (as Raft nodes), operators relying on "acknowledged ⇒ never lost across any failover pattern", and maintainers needing a live regression that reproduces and fences bug-851.
Impact: Today the replicated log, term propagation, reconcile, and commit are hand-rolled across persist.c/repl.c/ha.c/ha_convergence.c/config_repl.c and match by LSN only (term-blind). After this feature the controller delegates those decisions to the library through five new callbacks and three new entry points, stamps the real election term into the existing 40-byte WAL header, derives committed config from an in-log (term,lsn) marker instead of a separate index, and ships a live chaotic-multi-kill regression. Configuration bytes continue to move through the existing out-of-band config-push transport (OPCode_replConfigPush), hash-verified against the committed marker before activation. The Raft protocol itself (logOk, (term,index) truncation, current-term commit, freshness) is not in this spec — it is realized by the three library specs.
Goals
- Wire the controller adapter to the upstream callbacks verbatim (
on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced) and entry points (raft_log_append,raft_on_append_entries_request/reply,raft_advance_commit), stampingwal_entry_hdr_t.termfrom the library's term (R1) and routing incoming heartbeat/replication through the library so term adoption happens on every RPC (R2/R3). - Replace the term-blind safe-commit (
ha_compute_safe_committed_lsn) and committed-boundary reconcile (ha_rejoin_reconcile) with the library'scommit_index(viaon_commit_advanced) and(term,lsn)truncation (viaon_truncate_from), preserving the synchronous commit barrier + persist-before-ACK durability and the clean-failover behaviors of bugs 848/849/850 (R4/R5). - Order configuration changes through the same
(term,lsn)log as a lightweightWAL_CONFIG_MARKER(version_tag + sha256, no bytes); derive committed config from the latest committed marker; deliver the configuration bytes via the existing out-of-band config-push transport, hash-verified against the committed marker before activation (R7). - Ship a live
xce_testchaotic-multi-kill regression asserting cross-member(term,lsn)-fingerprinted committed-prefix identity (R6).
Non-Goals
- The Raft protocol itself — logOk/
prevLogTermmatch,(term,index)conflict truncation, the Figure-8 current-term commit rule,(lastLogTerm,lastLogIndex)freshness,UpdateTerm-on-any-RPC — owned byraft-log-replication+raft-commit-safety; consumed verbatim. - The commit-barrier / durability mechanism — the synchronous barrier, group-commit flusher, persist-before-ACK, reply gate, durable-floor freshness — owned by
ha-commit-durability; this spec only changes the barrier's input, never its mechanism. - The in-process conformance oracle — Election Safety / Log Matching / Leader Completeness / State Machine Safety in
raft_sim.c— owned byraft-sim-conformance; the R6 live regression complements it, it does not duplicate it. - Configuration-upgrade materialized-state consistency — establishing a full entity-bearing state snapshot at the change boundary so no member re-derives post-upgrade state (former R8) — is descoped to the independent
config-upgrade-snapshotspec, which HARD-depends onentity-bearing-snapshots. It is out of scope here; this spec delivers the config marker (consensus) plus out-of-band config-push byte delivery, not a full upgrade snapshot. - Voting-set membership changes / joint consensus (
ha-config-bootstrap); bugs 854/847/845/828 (tracked separately).
Boundary Commitments
This Spec Owns
- The controller callback-adapter bodies implementing the five upstream callbacks over the WAL / repl transport / commit barrier (and an in-process route for incoming AppendEntries/heartbeat).
wal_entry_hdr_t.termvalue stamping: apersist_set_termseam so every append records the library's live term; readinghdr.termback onreplay_waland feeding it toon_get_entry_term+ the library's restore (initial_log_len, last-log-term).ha_convergencedelegation: replacing the committed-boundary truncate with routing the leader's AppendEntries throughraft_on_append_entries_request+on_truncate_from.- Commit-source switch: feeding the library's
commit_index(viaon_commit_advanced) into the existing synchronous barrier, retiring the term-blindha_compute_safe_committed_lsnas the commit source. WAL_CONFIG_MARKER(R7): the new WAL entry type + append-via-raft_log_append+ committed-config-derived-from-latest-committed-marker, retiringcommitted_config_tagas an authoritative index.- Out-of-band config-push byte delivery + hash-verify-before-activate (R7): keying the existing config-push transport (
OPCode_replConfigPush) to the committed marker's version_tag + sha256, verifying held bytes against the marker hash before activation, and fencing a member from serving under mismatched configuration. - The live R6 regression: the chaotic-multi-kill
xce_testscript + thewalcomparecross-member(term,lsn,pcrc)divergence tool.
Out of Boundary
- All Raft protocol decisions (library:
raft-log-replication,raft-commit-safety). - The commit barrier, durability/fsync, persist-before-ACK, durable-floor freshness mechanism (
ha-commit-durability). - The conformance oracle / four-invariant checkers (
raft-sim-conformance). - Configuration-upgrade materialized-state consistency — the full entity-bearing state snapshot at the change boundary (so no member re-derives post-upgrade state) and its
xzarray_freeze/thaw+ entity encode/decode payload — is descoped to the independentconfig-upgrade-snapshotspec (which HARD-depends onentity-bearing-snapshots). This spec ships the config marker (consensus) + out-of-band config-push byte delivery; it does not build the upgrade snapshot or entity serialization. - Membership/quorum transitions (
ha-config-bootstrap); the on-disk WAL byte layout (unchanged — value fix only).
Allowed Dependencies
src/lib/raft/raft.{c,h}after the three upstream specs land: the five callbacks + entry points +raft_append_result_t+commit_index/next_index[]/match_index[]+(lastLogTerm,lastLogIndex)freshness, consumed verbatim.- The existing controller infrastructure:
persist.cWAL/recovery,repl.ctransport,commit.csynchronous barrier + durability (ha-commit-durability), the out-of-band config-push transport (OPCode_replConfigPush,config_repl.c/ha.c), the ten already-wired election callbacks (s_real_cbs,ha.c:1309-1320). - The test infrastructure:
waldump.{c,h},xce_testscript primitives,/xce-deploy-ha.
Revalidation Triggers
Re-check this design if any of the following change:
- The signature/semantics of any upstream callback (
on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced) or entry point (raft_log_append,raft_send_append_entries,raft_on_append_entries_request/reply,raft_advance_commit), includingraft_append_result_t. - The 1-based-index ↔ WAL-LSN identity or the "term 0 == absent" convention.
- The addition of
last_log_termtoraft_on_vote_request/raft_on_pre_vote_request(the controller must plumb it onto the wire and read it fromhdr.termon recovery). - The
commit_indexrestore convention (initial_commit_index) orinitial_log_len. - The config-push transport contract (
OPCode_replConfigPush) or the marker's{version_tag, sha256}key — the byte delivery is hash-verified against the committed marker before activation. - The synchronous commit barrier / persist-before-ACK contract (
ha-commit-durability) —on_commit_advancedmust continue to trigger it unchanged. - The split of the configuration-upgrade materialized-state work into
config-upgrade-snapshot— if that spec changes how it consumes the(term,lsn)config marker, re-check the marker contract here.
Architecture
Existing Architecture Analysis
The controller already drives the election half of the library through ten callbacks wired in s_real_cbs (ha.c:1309-1320): vote/PreVote send, freshness gate (raft_cb_vote_eligible, ha.c:299-360), promotion/demotion (raft_cb_become_leader/follower, ha.c:880-1242), and term persistence (raft_cb_persist → ha_persist_term, ha.c:1251-1294, 2441-2474). The controller uses only src/lib/raft/raft.c (no legacy src/controller/raft.c). The log half is hand-rolled and term-blind:
- Every WAL append stamps
hdr.term = p->term(persist.c:588/726/1040) butp->termis never assigned → term 0 everywhere (G1). The 40-byte header already has thetermfield (wal_types.h:29-39) — a value fix, not a layout change. - Incoming heartbeats/replication never adopt a higher term (
ha_consume_heartbeat_committeddrops stale only,ha.c:5013-5036) (G5). - Reconcile truncates by committed-boundary, not
(term,lsn)(ha_rejoin_reconcile,ha_convergence.c:295-366;persist_truncate_wal_aboveLSN-only,persist.c:2455-2565) (G3). - Safe commit is computed term-blind from voter LSNs (
ha_compute_safe_committed_lsn,ha.c:480-509) (G4); config commits through a separatecommitted_config_tagwith no term (config_repl.c:941-979), with the bytes pushed out-of-band viaOPCode_replConfigPush— the marker (consensus) will replace the term-blind index, while the byte transport is reused as-is.
The durability layer (ha-commit-durability) is correct and must be preserved: majority_candidate_locked self-caps at persist_durable_lsn (commit.c:79-138), persist-before-ACK on standbys (persist_wal_write_replicated), the pre-promotion eligibility fence (ha.c:933-951), advertise-real-durable-floor freshness, and the contiguity guard (persist.c:691-706, bug-850).
This design follows the same Ports-and-Adapters discipline already established by the election callbacks: the library decides; the controller performs I/O.
Architecture Pattern & Boundary Map
flowchart TB
subgraph Library["src/lib/raft/raft.c — PROTOCOL OWNER (NOT this spec)"]
APP["raft_log_append (ClientRequest)"]
AEREQ["raft_on_append_entries_request<br/>UpdateTerm + logOk + truncate"]
AEREP["raft_on_append_entries_reply"]
ADV["raft_advance_commit<br/>current-term Figure-8"]
VOTE["vote freshness lastLogTerm,lastLogIndex"]
end
subgraph Adapter["src/controller — THIN ADAPTER (this spec)"]
PE["on_persist_entry<br/>persist_set_term + WAL write"]
GET["on_get_entry_term<br/>WAL index / replay read-back"]
SEND["on_send_append_entries<br/>repl envelope prevLogTerm+entryTerm"]
TRUNC["on_truncate_from<br/>persist_truncate_wal_above"]
COMMIT["on_commit_advanced<br/>TRIGGER existing sync barrier"]
ROUTE["heartbeat/repl intake -> raft_on_append_entries_request<br/>(replaces ha_consume_heartbeat + ha_rejoin_reconcile)"]
end
subgraph Config["Config marker + out-of-band byte delivery (this spec)"]
MARK["WAL_CONFIG_MARKER<br/>version_tag + sha256 via raft_log_append"]
CPUSH["config-push byte delivery<br/>OPCode_replConfigPush, hash-verify vs marker before activate"]
end
subgraph Preserved["PRESERVED policy (other specs)"]
BARRIER["sync commit barrier + persist-before-ACK<br/>ha-commit-durability"]
CFGXPORT["existing config-push transport<br/>OPCode_replConfigPush"]
end
subgraph Test["Live regression (this spec)"]
WC["walcompare term,lsn,pcrc"]
SCRIPT["xce_test chaotic multi-kill"]
end
APP --> PE
AEREQ --> GET & PE & TRUNC
ROUTE --> AEREQ
AEREP --> SEND
ADV --> COMMIT
COMMIT --> BARRIER
MARK --> APP
CPUSH --> CFGXPORT
SCRIPT --> WC
Architecture Integration:
- Selected pattern: thin callback adapter (Ports-and-Adapters), identical to the election-callback pattern already in
ha.c. The library owns protocol; the controller owns I/O. - Domain boundaries: protocol decisions (append/truncate/commit/vote) in
raft.c; durable I/O (WAL write, repl send, barrier, config-push transport) in the controller; the marker + out-of-band byte delivery in this spec; the configuration-upgrade materialized-state snapshot is descoped toconfig-upgrade-snapshot. - Existing patterns preserved:
s_real_cbscallback wiring, persist-before-ACK, the synchronous barrier, the pre-promotion fence, the contiguity guard, "controller never self-terminates". - Steering compliance:
ha-async-io.md(no blocking in iomgr/vtask; barrier on its own path),persistence.md(durable floor / acknowledged⇒durable preserved; never persist commit index),error-handling.md(status returns, fail-loud non-promotable fence, neverexit/abort),testing.md(TDD; tests intestsuite/before C changes).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Protocol core | src/lib/raft/raft.c (gnu17, after 3 upstream specs) |
Owns Raft log/commit/freshness | Consumed verbatim; not modified here |
| Adapter / WAL | src/controller/persist.c + wal_types.h |
hdr.term stamping, recovery read-back, truncate |
40-byte header unchanged; persist_set_term seam added |
| Adapter / transport | src/controller/repl.c, typemap.h |
carry prevLogTerm + entry term on AppendEntries |
extend repl envelope fields |
| Adapter / commit | src/controller/commit.c (ha-commit-durability) |
barrier triggered by on_commit_advanced |
mechanism unchanged |
| Config | src/controller/config_repl.c + wal_types.h |
WAL_CONFIG_MARKER, marker-derived committed config |
retires committed_config_tag index |
| Config bytes | existing config-push transport (OPCode_replConfigPush, config_repl.c/ha.c) |
deliver bytes out-of-band; hash-verify vs marker before activate | transport reused as-is; no snapshot |
| Test | xce_test + waldump.{c,h} + /xce-deploy-ha |
live chaotic regression + walcompare |
~90% infra exists |
File Structure Plan
Modified Files
src/include/wal_types.h— addWAL_CONFIG_MARKER = 50towal_entry_type; add the marker payload structwal_config_marker_t { uint64_t version_tag; uint8_t sha256[32]; }(no config bytes). No change towal_entry_hdr_t(40 bytes preserved, R1.7).src/include/persist.h— declarepersist_set_term(persist_t *p, uint64_t term); declare recovery accessors that surface per-entry term + restoredlog_len/last-log-term for the library init.src/controller/persist.c— implementpersist_set_term(setsp->term); makereplay_walretainhdr.termper LSN (in-memory term index) soon_get_entry_termand library restore can read it;persist_truncate_wal_abovereused unchanged byon_truncate_from.src/controller/repl.c+src/include/typemap.h— extend the AppendEntries-carrying envelope (wal_catchup_entry_t/capnp_repl_envelope_t) to carryprev_log_index,prev_log_term, and the entry'stermexplicitly, so a standby persists the leader's term (not localp->term).src/controller/ha.c— wire the five new callbacks intos_real_cbs(on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced); add the heartbeat/replication intake that routes throughraft_on_append_entries_request(replacing the term-blindha_consume_heartbeat_committedterm handling); driveraft_log_appendon the primary append path; passlast_log_termon the vote wire and decode it on intake; retireha_compute_safe_committed_lsnas the commit source (the library'scommit_indexdrives the barrier viaon_commit_advanced).src/controller/ha_convergence.c— replaceha_rejoin_reconcile's committed-boundary truncate with delegation: route the leader's AppendEntries through the library;on_truncate_fromperforms the truncate. Keep the non-promotable retry-fence behavior (R3.5/R5).src/controller/config_repl.c—do_upgrade_config_with_bytesappends aWAL_CONFIG_MARKERviaraft_log_append(replacing thecommitted_config_tagpush/commit); derive committed config from the latest committed marker in the committed prefix; key the existing out-of-band config-push (OPCode_replConfigPush) to the marker's{version_tag, sha256}, hash-verify the held bytes against the committed marker before activating, and fence a member from serving under mismatched configuration (R7.5/R7.8–R7.10). The full entity-bearing upgrade snapshot is out of scope (config-upgrade-snapshot).src/controller/commit.c— no mechanism change;on_commit_advancedcalls into the existingcommit_advance_locked/commit_set_writable_lsnpath with the library-suppliedcommit_index.
New Files
src/controller/testsuite/walcompare.{c,h}— cross-member WAL diff by(lsn → term, pcrc); nonzero exit on committed-prefix divergence. Reuses thewaldump.creader/decoder (roadmap-planned tooling,roadmap.md:55).src/controller/testsuite/test_term_safety_adapter.c— unit/integration tests for the adapter callbacks (term stamping,on_get_entry_termread-back, truncate delegation,on_commit_advancedbarrier trigger, marker append/derive).src/tools/test-term-safety-chaos.sh(or anxce_testscript undertestsuite/) — the R6 chaotic-multi-kill driver invokingwalcompare.
Each file keeps one responsibility: protocol stays in
raft.c(untouched);persist.c= WAL I/O + term value;ha.c/ha_convergence.c= callback bodies + intake routing;config_repl.c= marker + out-of-band config-push byte delivery;testsuite/= regression. The File Structure Plan reflects the boundary: no protocol logic file is created or modified, and no upgrade-snapshot file is added (descoped toconfig-upgrade-snapshot).
System Flows
Replicated append → standby persist with the leader's term (R1, R2, R3)
sequenceDiagram
participant P as Primary library (raft.c)
participant PA as Primary adapter (this spec)
participant SA as Standby adapter (this spec)
participant S as Standby library (raft.c)
Note over P: raft_log_append(value) — stamps s->term
P->>PA: on_persist_entry(idx, term, entry)
PA->>PA: persist_set_term(p, term) then WAL write hdr.term=term
Note over P: raft_send_append_entries(peer, commit_index)
P->>PA: on_send_append_entries(peer, prevIdx, prevTerm, leaderCommit, entry+entryTerm)
PA-->>SA: repl envelope (carries prevLogTerm + entryTerm)
Note over S: raft_on_append_entries_request()<br/>UpdateTerm FIRST (G5) then logOk (G2)
S->>SA: on_get_entry_term(prevIdx) %% logOk
alt term conflict at idx (G3)
S->>SA: on_truncate_from(idx) then on_persist_entry(idx, leader_term)
else append
S->>SA: on_persist_entry(idx, leader_term) %% standby persists LEADER term
end
Note over SA: persist-before-ACK preserved -> WalAck only on durable
Flow-level decisions:
- The standby persists the leader's entry term (carried explicitly on the envelope), closing the
persist_wal_write_replicated"best-effortp->term" hazard.persist_set_termkeepsp->termaligned with the library term for the local-append path. - Incoming heartbeats are 0-entry AppendEntries routed through the same
raft_on_append_entries_request, so a higher-term heartbeat triggersUpdateTerm(G5) — the controller no longer needsha_consume_heartbeat_committedto adopt term. - The contiguity guard (bug-850) and persist-before-ACK (ha-commit-durability) are unchanged and sit beneath
on_persist_entry.
Commit advance → existing barrier; config marker (R4, R7)
sequenceDiagram
participant L as Leader library (raft.c)
participant A as Adapter (this spec)
participant B as Sync commit barrier (ha-commit-durability, PRESERVED)
Note over L: replication updated match_index[] (library)
Note over L: raft_advance_commit() — current-term Figure-8 (G4)
alt commit_index strictly advances
L->>A: on_commit_advanced(commit_index)
A->>B: feed safe commit position into existing barrier
Note over B: majority self-cap at durable_lsn + persist-before-ACK<br/>+ own-term writable gate (UNCHANGED)
else held (prev-term / no quorum)
Note over L: no callback, barrier untouched
end
Note over A: committed config = latest WAL_CONFIG_MARKER<br/>within committed prefix (derived, not a separate index)
Flow-level decisions:
on_commit_advancedtriggers the barrier; it never performs durability itself.ha_compute_safe_committed_lsnis retired as the commit source (the library's current-term-gatedcommit_indexreplaces it), but the post-election truncate-to-safe at promotion and the durable self-cap remain (R5).- A config change is an ordinary committed log entry: the
WAL_CONFIG_MARKERcommits through the same barrier and reconciles through the same(term,lsn)matching (R7.1/R7.3/R7.6/R7.11). The committed config is the latest committed marker (R7.7).
Configuration bytes — out-of-band config-push, hash-verified against the committed marker (R7.5, R7.8–R7.10)
flowchart TB
M["WAL_CONFIG_MARKER commits at term,lsn<br/>(version_tag + sha256, no bytes)"] --> NEED{"local bytes match<br/>marker sha256?"}
NEED -->|no| PUSH["obtain bytes via existing config-push<br/>OPCode_replConfigPush, keyed to version_tag"]
PUSH --> VERIFY["verify obtained bytes sha256 == marker hash"]
NEED -->|yes| VERIFY
VERIFY -->|match| ACTIVATE["activate configuration"]
VERIFY -->|mismatch| FENCE["serve-fence: keep member non-serving<br/>under new config; retry (R7.9)"]
Flow-level decision: the marker (consensus) is the authority; the bytes travel out-of-band on the existing config-push transport (OPCode_replConfigPush), never in a WAL payload (R7.4). A member obtains and hash-verifies the bytes against the committed marker before activating (R7.8), fences itself on mismatch (R7.9), and on recovery activates the configuration referenced by the latest committed marker (R7.10). The full materialized-state upgrade snapshot (former R8) is out of scope — see config-upgrade-snapshot.
Requirements Traceability
| Requirement | Summary | Adapter component(s) | Library/upstream realization | Flow |
|---|---|---|---|---|
| 1.1, 1.2 | record live/promotion term in entry | on_persist_entry + persist_set_term; raft_log_append (incl. own-term NOOP) |
library stamps s->term |
Append flow |
| 1.3, 1.4 | transmit + persist leader term on replication | on_send_append_entries carries entryTerm; standby on_persist_entry writes leader term |
raft_on_append_entries_request |
Append flow |
| 1.5 | recovery reads back per-entry term | replay_wal term index; on_get_entry_term; restore initial_log_len/last-log-term |
library init restore | — |
| 1.6 | no zero/placeholder term while term valid | persist_set_term keeps p->term live |
— | — |
| 1.7 | preserve 40-byte WAL layout (value fix) | wal_entry_hdr_t unchanged |
— | — |
| 1.8 | legacy zero term = lowest-known for matching | on_get_entry_term maps legacy 0 → lowest-known |
library (term,index) match tolerant |
— |
| 2.1, 2.2 | persist won term before appending; not committed until majority | raft_cb_persist→ha_persist_term; own-term WAL_NOOP gate (commit_set_writable_lsn) |
BecomeLeader + commit rule |
Commit flow |
| 2.3, 2.4, 2.6 | no re-elect at stale term; adopt higher term on any RPC | heartbeat/repl intake → raft_on_append_entries_request; on_persist |
UpdateTerm (G5) |
Append flow |
| 2.5 | at most one leader advances committed per term | library current-term commit + freshness | raft-commit-safety G4/G6 |
Commit flow |
| 3.1, 3.2 | reconcile by (term,lsn); truncate divergent suffix |
ha_convergence delegation → on_truncate_from |
raft_on_append_entries_request (G3) |
Append flow |
| 3.3 | never truncate committed prefix | library truncates only above match point; on_truncate_from honors it |
library | — |
| 3.4 | reconciled prefix byte-identical to leader | standby persists leader entry+term | library | Append flow |
| 3.5, 3.6 | non-promotable retry; no false committed | keep loud non-promotable fence; do not report divergent as committed | ha-commit-durability fence preserved |
— |
| 4.1, 4.5 | advance committed only on (term,lsn) quorum + current-term |
on_commit_advanced feeds barrier with library commit_index |
raft_advance_commit (G4) |
Commit flow |
| 4.2, 4.3, 4.4 | committed entry byte-identical/preserved across leadership | library Leader Completeness + barrier durability | library + ha-commit-durability |
Commit flow |
| 5.1 | clean single failover converges identically | unchanged promotion truncate-to-safe + library matching | raft_cb_become_leader |
— |
| 5.2 | recovery-on-promotion + advertise-real-LSN (848/849) | raft_cb_become_leader + raft_cb_vote_eligible unchanged |
preserved | — |
| 5.3 | contiguity guard, no forward gap (850) | persist_wal_write_replicated guard unchanged beneath on_persist_entry |
preserved | — |
| 5.4 | no added steady-state ack latency | barrier path untouched; on_commit_advanced only changes source |
preserved | — |
| 6.1, 6.2, 6.3 | chaotic regression asserts (term,lsn,content) prefix identity |
walcompare + chaos script |
— | — |
| 6.4 | no acked-committed job absent post-run | expect-job $jobid per acked submit |
— | — |
| 6.5 | pre-fix repro fails, post-fix passes | negative-direction assertion (term=0 pre-fix) | — | — |
| 6.6 | config changes interleaved → same committed config ref | walcompare checks committed marker tag+hash |
R7 derive | Config flow |
| 7.1–7.4, 7.6, 7.7, 7.11 | config as (term,lsn) marker, no bytes, term-stamped, reconciled, derived, majority-committed |
WAL_CONFIG_MARKER via raft_log_append; marker-derived committed config |
library log/commit/reconcile | Config marker flow |
| 7.5, 7.8, 7.9, 7.10 | bytes via out-of-band config-push; verify hash before activate; fence on mismatch; activate from latest committed marker on recovery | config-push byte delivery (OPCode_replConfigPush) keyed to marker {version_tag, sha256} + hash-verify-before-activate + serve-fence |
existing config-push transport | Config bytes flow |
Components and Interfaces
| Component | Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| WAL term adapter | Adapter | Stamp/read-back the library term in hdr.term |
1.1–1.8 | library callbacks (P0), persist.c (P0) |
Service, State |
| AppendEntries intake/route | Adapter | Route heartbeat/repl through the library; term adoption | 2.3–2.6, 3.1–3.4 | raft_on_append_entries_request (P0), repl envelope (P0) |
Service |
| Reconcile delegation | Adapter | Replace committed-boundary truncate with on_truncate_from |
3.1–3.6 | library (P0), persist_truncate_wal_above (P0), fence (P1) |
Service |
| Commit-source switch | Adapter | Feed library commit_index into existing barrier |
4.1–4.5, 5.4 | on_commit_advanced (P0), commit.c barrier (P0) |
Service |
| Clean-failover preservation | Adapter | Keep 848/849/850 behaviors unchanged | 5.1–5.4 | promotion/vote callbacks, contiguity guard (P0) | Service |
| Config marker | Adapter | WAL_CONFIG_MARKER in unified log; derive committed config |
7.1–7.4,7.6,7.7,7.11 | raft_log_append (P0), config_repl.c (P0) |
Service, State |
| Config byte delivery | Adapter | Out-of-band config-push keyed to marker; hash-verify-before-activate; serve-fence on mismatch | 7.5,7.8,7.9,7.10 | config-push transport OPCode_replConfigPush (P0), config_repl.c (P0) |
Service |
| Live regression | Test | Chaotic-multi-kill + walcompare |
6.1–6.6 | xce_test, waldump, /xce-deploy-ha (P0) |
Test |
Adapter Callback → Controller Implementation Site (the core seam table)
| Library callback / entry point (upstream) | Controller implementation site | Behavior |
|---|---|---|
on_persist_entry(ctx,index,term,entry,len) |
ha.c callback → persist_set_term(p,term) + persist_wal_async/persist_wal_write_replicated (persist.c:588/726) |
Stamp hdr.term=term at LSN=index; persist-before-ACK preserved. |
on_get_entry_term(ctx,index)->term|0 |
ha.c callback → in-memory WAL term index from replay_wal/live append |
Return recorded hdr.term for LSN; legacy 0 → lowest-known (R1.8); 0 if absent. |
on_send_append_entries(ctx,peer,prevIdx,prevTerm,leaderCommit,...) |
ha.c callback → repl.c envelope build (typemap.h extended fields) |
Enqueue AppendEntries carrying prevLogTerm + entry term; leaderCommit = commit_index. |
on_truncate_from(ctx,index) |
ha.c callback → persist_truncate_wal_above(p,index-1) (persist.c:2455) |
Delete LSN≥index; replaces ha_rejoin_reconcile committed-boundary heuristic. |
on_commit_advanced(ctx,commit_index) |
ha.c callback → commit.c existing barrier (commit_advance_locked/commit_set_writable_lsn) |
Feed safe commit position; trigger barrier, do not replace it. |
raft_log_append(s,entry,len,&idx) |
primary append path in ha.c/config_repl.c (incl. WAL_CONFIG_MARKER) |
Library stamps s->term, assigns LSN; drives on_persist_entry. |
raft_on_append_entries_request(...) |
heartbeat/repl intake in ha.c (replaces ha_consume_heartbeat_committed term handling) |
Library does UpdateTerm+logOk+truncate/append via callbacks. |
raft_on_append_entries_reply(...) |
repl ACK intake in ha.c |
Library updates match_index[]/next_index[]. |
raft_advance_commit(s) |
driven after repl ACK in ha.c |
Library computes current-term-gated commit_index; may fire on_commit_advanced. |
raft_on_vote_request/pre_vote_request(...,last_log_term,...) |
existing vote callbacks in ha.c, extended |
Controller decodes candidate last_log_term from wire; reads own via on_get_entry_term(log_len). |
WAL term adapter
| Field | Detail |
|---|---|
| Intent | Make hdr.term carry the library's election term on every entry and surface it on recovery |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8 |
Responsibilities & Constraints
persist_set_term(persist_t *p, uint64_t term)assignsp->term(the missing seam); called byon_persist_entryand on term adoption so no append stamps 0 while a valid term is in effect (R1.6). On-diskwal_entry_hdr_tis unchanged (40 bytes, R1.7).replay_walretains eachhdr.termin an in-memory per-LSN index soon_get_entry_termanswers from recovered state (R1.5); a legacy entry with term 0 maps to the lowest-known term for matching (R1.8).- Standbys persist the leader's entry term (carried on the envelope), not local
p->term(R1.3/R1.4).
Contracts: Service [x] / State [x]
Service Interface
/* Set the controller's live election term so subsequent WAL appends stamp it
* into hdr.term (R1.1/R1.6). Called from on_persist_entry and on term adoption.
* No on-disk layout change; value fix only (R1.7). */
void persist_set_term(persist_t *p, uint64_t term);
/* Recovered per-entry term for an LSN (1-based == index), feeding on_get_entry_term
* and the library's init restore. Returns 0 when absent; legacy-0 is mapped to the
* lowest-known term by the adapter for (term,lsn) matching (R1.8). */
uint64_t persist_entry_term(const persist_t *p, uint64_t lsn);
- Preconditions:
pvalid;lsnwithin[1, current_lsn). - Postconditions:
on_get_entry_term(lsn)returns the recorded term; appends stamp the live term. - Invariants:
hdr.termnever 0 while a valid term is in effect; 40-byte layout preserved.
Config marker
| Field | Detail |
|---|---|
| Intent | Order config changes through the unified (term,lsn) log as a lightweight marker |
| Requirements | 7.1, 7.2, 7.3, 7.4, 7.6, 7.7, 7.11 |
Responsibilities & Constraints
do_upgrade_config_with_bytes(config_repl.c:1001-1163) appendsWAL_CONFIG_MARKER(wal_config_marker_t = {version_tag, sha256[32]}, no bytes, R7.2/R7.4) viaraft_log_append, so it inherits term-stamping (R7.3), commit (R7.11), and(term,lsn)reconcile/truncate (R7.6).- Committed config is derived from the latest committed marker within the committed prefix (R7.7); the separate
committed_config_tagindex is retired as authoritative (it may remain as a derived cache).
Contracts: Service [x] / State [x]
Service Interface
/* Append a config-change marker (version_tag + sha256, no config bytes) to the
* unified replicated log via raft_log_append. Returns RAFT_OK / the append rc.
* The config bytes travel out-of-band via config-push (R7.5), never the marker. */
int config_append_marker(cluster_t *c, uint64_t version_tag,
const uint8_t sha256[32], uint64_t *out_lsn);
/* Derive the committed configuration reference from the latest WAL_CONFIG_MARKER
* at or below committed_lsn (R7.7). */
int config_committed_marker(const cluster_t *c, uint64_t *version_tag,
uint8_t sha256_out[32]);
- Preconditions: leader for append; valid
sha256. - Postconditions: marker committed ⇒ committed config reference advances; reconcile truncates a divergent marker exactly like a job entry.
- Invariants: config bytes never enter a WAL payload (R7.4).
Config byte delivery
| Field | Detail |
|---|---|
| Intent | Deliver config bytes out-of-band via the existing config-push transport; hash-verify against the committed marker before activation |
| Requirements | 7.5, 7.8, 7.9, 7.10 |
Responsibilities & Constraints
- Move the configuration bytes through the existing out-of-band config-push transport (
OPCode_replConfigPush,config_repl.c/ha.c), keyed to the committed marker's{version_tag, sha256}— never through a WAL payload (R7.4/R7.5). - When a committed marker references bytes not yet held locally, obtain them via config-push and verify the obtained bytes' sha256 against the marker hash before activating (R7.8).
- If a member's locally held bytes do not match the latest committed marker's hash, keep the member from serving under the new configuration and keep retrying rather than activating mismatched config (R7.9) — the "controller never self-terminates" loud non-promotable fence, never
exit/abort. - On recovery, activate the configuration referenced by the latest committed marker, obtaining the bytes from out-of-band storage (R7.10).
Contracts: Service [x]
Service Interface
/* Ensure the bytes for the latest committed config marker are held locally and
* hash-verified against the marker, then activate. Obtains bytes via config-push
* (OPCode_replConfigPush) when absent. Returns RAFT_OK on activate; a non-success
* rc keeps the member fenced (non-serving under the new config) and retrying
* (R7.8/R7.9/R7.10). */
int config_activate_committed(cluster_t *c);
- Preconditions: a committed
WAL_CONFIG_MARKERexists in the committed prefix. - Postconditions: on success the active config matches the marker's
{version_tag, sha256}; on hash mismatch the member stays fenced and retries. - Invariants: bytes are never sourced from a WAL payload; activation only after hash-verify against the committed marker.
Note
: the full materialized-state upgrade snapshot (embed entities + config in one artifact, install on every member so no member re-derives post-upgrade state — former R8) is out of scope here and lives in the independent
config-upgrade-snapshotspec, which HARD-depends onentity-bearing-snapshots. This spec delivers config bytes via the interim out-of-band config-push path thatconfig-upgrade-snapshotlater supersedes for the upgrade case.
Live regression (R6)
| Field | Detail |
|---|---|
| Intent | Reproduce bug-851 and assert cross-member (term,lsn) committed-prefix identity |
| Requirements | 6.1, 6.2, 6.3, 6.4, 6.5, 6.6 |
Responsibilities & Constraints
walcompare <dirA> <dirB> ...diffs members by(lsn → term, pcrc); nonzero exit on any committed-prefix divergence (R6.1/R6.2/R6.3), reusingwaldump.c's decoder.- The chaos script drives repeated primary kills interleaved with
jobsub+ config upgrades via/xce-deploy-ha+kill_node/start_node+ deterministic--wal-sync-ms, then assertsexpect-job $jobidfor every acked submit (R6.4) andwalcompareidentity incl. committed config marker (R6.6). - R6.5: on pre-fix code (term=0) the repro must fail (
walcomparedetects divergence /expect-jobloss); on fixed code it passes.
Contracts: Test [x]
Data Models
Domain Model
- Log entry:
(index==LSN, term, type, payload)— the unified(term,lsn)log for both job state and config markers. Index 1-based == WAL LSN (library convention). - Config-change marker:
wal_config_marker_t = {uint64_t version_tag, uint8_t sha256[32]}— a reference, never the bytes. - Committed config: derived value = the latest
WAL_CONFIG_MARKERat or belowcommitted_lsn.
Logical Data Model
wal_entry_typegainsWAL_CONFIG_MARKER = 50(betweenWAL_NOOP=40andWAL_CHECKPOINT=100).persist_t.termbecomes a written field viapersist_set_term; a recovered per-LSN term index is added topersist_t(in-memory only).- The repl envelope (
wal_catchup_entry_t/capnp_repl_envelope_t) gains explicitprev_log_index,prev_log_term, and entrytermfields on the AppendEntries path. - No on-disk WAL layout change (R1.7):
wal_entry_hdr_tstays 40 bytes withtermat offset 0.
Data Contracts & Integration
- AppendEntries wire: carries
(prevLogIndex, prevLogTerm, leaderCommit, entry, entryTerm)— the standby persistsentryTerm, not localp->term. - Vote wire: carries
(lastLogTerm, lastLogIndex); the controller decodeslastLogTermand passes it to the library; own value viaon_get_entry_term(log_len). - Config bytes: delivered out-of-band via config-push (
OPCode_replConfigPush), keyed to the marker's{version_tag, sha256}, hash-verified against the committed marker before activation; never in a WAL payload. The full materialized-state upgrade snapshot is out of scope (config-upgrade-snapshot).
Error Handling
Error Strategy
The adapter follows "controller never self-terminates" (error-handling.md, ha-async-io.md): callbacks return status; on a non-recoverable condition the controller enters a loud, non-promotable fence and keeps retrying — it never exit/abort. Persist-before-ACK and the durable self-cap are unchanged.
Error Categories and Responses
- Persist failure (
on_persist_entryrc≠0) → propagate; library does not advance the local log; standby does not ACK (persist-before-ACK preserved). - Truncate failure (
on_truncate_fromrc≠0) → report a non-success request result so the leader retries; the member stays non-promotable until reconciled (R3.5). - Unreconcilable member → loud permanent fence, non-promotable, keep retrying catch-up (R3.5/R5; never report false convergence, R3.6).
- Config hash mismatch (R7.9) → keep the member from serving under the new config; retry; do not activate mismatched config (
config_activate_committedreturns non-success; member stays fenced). - Config bytes absent for a committed marker (R7.8) → obtain via config-push and hash-verify before activate; do not activate on a missing or mismatched payload.
Monitoring
Reuse raft_cb_trace and existing INFO/ERROR logging for: term-stamp, term-adopt-on-AE, conflict-truncate, commit-advance-trigger, config-marker-commit, config-byte-verify, and the loud fences.
Testing Strategy
TDD-mandatory (testing.md): tests in src/controller/testsuite/ precede C changes; gate on the ANSI-stripped :FAIL: count; run final durability/regression validation on a real fsync-able filesystem.
Unit Tests (adapter callbacks, in test_term_safety_adapter.c)
on_persist_entrystampshdr.termfrom the library term (viapersist_set_term) andon_get_entry_termreads it back; legacy-0 maps to lowest-known (R1.1–1.8).- AppendEntries intake routes a higher-term heartbeat through
raft_on_append_entries_request, the library adopts the term, andha_persist_termrecords it (R2.3–2.6 / G5). on_truncate_fromdeletes the divergent suffix and the standby persists the leader entry+term; committed prefix untouched (R3.1–3.4 / G3).on_commit_advancedfeeds the librarycommit_indexinto the existing barrier and the WAIT entry releases only on durable majority;ha_compute_safe_committed_lsnno longer the commit source (R4.1/R4.5, R5.4).config_append_markerappendsWAL_CONFIG_MARKER(no bytes) andconfig_committed_markerderives committed config from the latest committed marker; a divergent marker truncates like a job entry (R7.1–7.7).
Integration Tests
- Clean single failover converges to byte-identical WALs incl. terms, preserving recovery-on-promotion + advertise-real-LSN + contiguity (R5.1–5.3).
- Marker commit + reconcile under a forced leader change keeps every member on the same committed config reference (R6.6, R7.11).
- Config byte delivery: a committed marker whose bytes are obtained via config-push activates only after the bytes' sha256 matches the marker hash; an injected hash mismatch keeps the member fenced (non-serving under the new config) and retrying (R7.8/R7.9/R7.10).
E2E / Live Regression (R6 — the headline)
xce_testchaotic-multi-kill: 3-node cluster, repeated primary SIGKILL interleaved withjobsuband config upgrades under deterministic--wal-sync-ms; after the runwalcompareasserts(term,lsn,pcrc)committed-prefix identity across all members andexpect-job $jobidconfirms no acked-committed job is absent (R6.1–6.4, R6.6).- Negative direction: the same script on pre-fix code (term=0) reproduces bug-851 (committed-prefix divergence / job loss); on fixed code it passes (R6.5).
Resolved Scope Note (descope of former R8)
The former Requirement 8 (full entity-bearing configuration-upgrade snapshot) was a blocking item in the prior draft of this design: it required a full materialized-state snapshot at the change boundary, which depends on entity-bearing snapshots that do not exist yet (today snapshots are a 16-byte LSN sentinel; the xzarray_freeze/thaw machinery is built but dormant — the unstarted entity-bearing-snapshots backlog item).
Resolution (2026-06-21): the materialized-state-consistency-on-upgrade work is descoped into the independent config-upgrade-snapshot spec, which HARD-depends on entity-bearing-snapshots. It is out of scope here.
What this spec ships now (no snapshot dependency):
- R1–R6: per-entry term, monotonic term adoption,
(term,lsn)reconcile, term-safe committed prefix, and the live chaotic-multi-kill regression — all realized on the library + existing transport. - R7: the config-change marker (consensus,
(term,lsn), no bytes) in the unified log, with configuration bytes delivered via the existing out-of-band config-push transport (OPCode_replConfigPush), hash-verified against the committed marker before activation.config-upgrade-snapshotlater supersedes this interim byte delivery for the upgrade case once entity-bearing snapshots land.
Constraints preserved by the descope: config bytes are never carried in the WAL marker (R7.2/R7.4), and no member re-derives committed state from a stale separate index — the marker is the consensus authority.