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.
21 KiB
21 KiB
Research & Design Decisions
Summary
- Feature:
ha-term-safety - Discovery Scope: Complex Integration (controller-side adapter + config marker + out-of-band config-push byte delivery + live regression umbrella for the Raft TLA+ Conformance library series; the configuration-upgrade materialized-state snapshot is descoped to
config-upgrade-snapshot). - Key Findings:
- The WAL on-disk header already carries
wal_entry_hdr_t.termat offset 0 of a 40-byte layout (src/include/wal_types.h:29-39). R1.7 ("preserve on-disk layout, value fix only") is satisfied by the format as-is. The defect is purely thatpersist_t.term(src/include/persist.h:160) is never assigned anywhere — everyhdr.term = p->termsite (persist.c:588, 726, 1040) therefore stamps0. The fix is apersist_set_termseam fed by the library's term, plus readinghdr.termback on replay (replay_walreads but discards it today,persist.c:~1500-1620). - The controller uses the new library
src/lib/raft/raft.cexclusively (no legacysrc/controller/raft.cexists); ten election callbacks are already wired vias_real_cbs(ha.c:1309-1320). The log/commit callbacks defined by the three upstream specs (on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced) are not yet wired — that wiring is this spec's core deliverable. - G5 (term not adopted on the replication/heartbeat path) is confirmed in code: only
raft_on_vote_request/replyroute a higher term into raft state (raft.c:125-134→raft_cb_persist→ha_persist_term,ha.c:1251-1294, 2441-2474). The heartbeat consume path (ha_consume_heartbeat_committed,ha.c:5013-5036) only drops stale heartbeats; it never adopts a higher term. With the library now owningUpdateTerm-on-any-RPC, the controller must route incoming heartbeat/replication RPCs throughraft_on_append_entries_request. - G3 (reconcile is committed-boundary, not
(term,lsn)) is confirmed:ha_rejoin_reconcile(ha_convergence.c:295-366) clamps the truncation cutoff tocommitted_lsnand truncates the suffix blindly viapersist_truncate_wal_above(LSN-only,persist.c:2455-2565). It cannot detect same-LSN/different-term divergence. The library'sraft_on_append_entries_request+on_truncate_fromreplace this decision. - R8 SNAPSHOT FEASIBILITY → RESOLVED BY DESCOPE (2026-06-21): the snapshot-feasibility investigation found that snapshots are a 16-byte LSN sentinel stub today (
"XCESNAP\0"+ checkpoint_lsn;write_snapshotpersist.c:1068-1101,load_snapshotpersist.c:1280-1307) carrying zero entity state; the COW machinery a full upgrade snapshot needs (xzarray_freeze/xzarray_thaw,zarray.c:695-757) is built and unit-tested but dormant (kvmgr.c:297-298vfreeze = NULL /* wired in P0-2 */, zero production callers); the roadmap's "entity-bearing snapshots (Task 11)" (roadmap.md:43) is unstarted. Because the full configuration-upgrade materialized-state snapshot is a HARD dependency on entity-bearing-snapshots that does not exist, the former Requirement 8 was descoped into the independentconfig-upgrade-snapshotspec (which HARD-depends onentity-bearing-snapshots).ha-term-safetyships R1–R7 with no snapshot dependency: R7's config marker (consensus) is in-log, and the config bytes are delivered via the existing out-of-band config-push transport (OPCode_replConfigPush), hash-verified against the committed marker before activation. The config marker never depended on entity-bearing snapshots; only the descoped upgrade snapshot did.
- The WAL on-disk header already carries
Research Log
Upstream library callback contract (the seam this spec wires to)
- Context: This spec must delegate the Raft protocol to the library and wire the controller as a thin adapter, reusing the upstream callback names verbatim.
- Sources Consulted:
.kiro/specs/raft-log-replication/design.md,.kiro/specs/raft-commit-safety/design.md,.kiro/specs/raft-sim-conformance/design.md,TLA-gap-analysis.md. - Findings:
raft-log-replicationowns:raft_log_append,raft_send_append_entries,raft_on_append_entries_request,raft_on_append_entries_reply,raft_append_result_t; callbackson_persist_entry(ctx,index,term,entry,len),on_get_entry_term(ctx,index)->term|0,on_send_append_entries(ctx,peer,prevIdx,prevTerm,leaderCommit,...),on_truncate_from(ctx,index); per-peernext_index[]/match_index[]; index 1-based == WAL LSN; term 0 == absent;UpdateTermapplied on every RPC (G5); conflict branch = truncate-then-append in one handler call (G3).raft-commit-safetyowns:raft_advance_commit(s)with the Figure-8 current-term rule (G4);on_commit_advanced(ctx,commit_index)fired only on strict advance — triggers the existing barrier, never replaces it; vote/PreVote freshness becomes(lastLogTerm,lastLogIndex)lexicographic (G6), with the candidate'slast_log_termadded toraft_on_vote_request/raft_on_pre_vote_requestsignatures.raft-sim-conformanceowns the in-process oracle (Election Safety / Log Matching / Leader Completeness / State Machine Safety) — the verification cross-reference; the live regression here (R6) complements it (the sim proves the library; the live test proves the controller adapter wiring).
- Implications: Every R1–R6 protocol invariant is realized by the library; ha-term-safety implements the adapter callback bodies + the term plumbing on the wire + the reconcile delegation + the live regression. The controller must NOT re-implement
logOk, conflict truncation, the current-term commit rule, or freshness — those live inraft.c.
WAL term stamping & persistence seam (R1, R2)
- Sources Consulted:
wal_types.h:29-59,persist.h:160,persist.c:588/726/1040/1500-1620/2455-2565,typemap.h:338-387,repl.c:239-311. - Findings:
wal_entry_hdr_tis 40 bytes,_Static_assert-locked,termalready present at offset 0 → R1.7 is free.persist_t.termexists butgrep "p->term ="/"->term ="returns no assignment in persist.c → every entry stamps term 0 (G1). Fix: apersist_set_term(p, term)seam called from the adapter when the library establishes/adopts a term.- Replication transport: the active wire format is
wal_catchup_entry_t(typemap.h:338-351) which has no term field; the term currently rides implicitly inside the serialized header bytes.capnp_repl_envelope_t.termexists (typemap.h:380-387) but is a test-only mapped type. The adapter'son_send_append_entriesmust carryprevLogTerm+ the entry's term explicitly so a standby persists the leader's term, not its localp->term(closing thepersist_wal_write_replicated"best-effort p->term" hazard atpersist.c:719-726). replay_walreadshdr.termbut discards it; recovery must surface per-entry term to feedon_get_entry_termand restoreinitial_log_len/last-log-term for the library at init.- Next free WAL type id:
WAL_NOOP=40,WAL_CHECKPOINT=100→WAL_CONFIG_MARKER = 50slots cleanly (R7).
Term adoption, reconcile, commit, promotion seams (R2, R3, R4, R5)
- Sources Consulted:
ha.c:113-1320, 480-509, 880-1033, 5013-5036, 5338-5402,ha_convergence.c:240-384,commit.c:79-395,cluster.c:1200-1299,myapp.cstandby branch,persist.c:691-706. - Findings:
- R2/G5: heartbeat/replication never adopt a higher term (
ha_consume_heartbeat_committedonly drops stale). Routing incoming heartbeat/repl throughraft_on_append_entries_requestmakes the library doUpdateTerm; the adapter'son_persist(alreadyha_persist_term) durably records it. Monotonic-term durability before appending as leader (R2.1/2.2) is realized by the library'sBecomeLeader+ the own-termWAL_NOOPgate already present (raft_cb_become_leaderarmscommit_set_writable_lsn,ha.c:880-1033/commit.c:383-395). - R3/G3:
ha_rejoin_reconcilecommitted-boundary clamp replaced by routing the leader's AppendEntries throughraft_on_append_entries_request; the library decides match/truncate/append and callson_truncate_from(adapter →persist_truncate_wal_above) +on_persist_entry. - R4/G4:
ha_compute_safe_committed_lsn(ha.c:480-509) is term-blind (collects voter LSNs/tags, no term). It is superseded by the library'scommit_index(current-term-gated) delivered viaon_commit_advanced, which triggers the existing synchronous barrier. The barrier and durability (majority_candidate_lockedself-caps atpersist_durable_lsn,commit.c:79-138; persist-before-ACK;wait_done_lockedown-term gate) are PRESERVED AS POLICY (ha-commit-durability) — only the input (safe commit position) changes source. - R5: clean-failover behaviors to preserve — recovery-on-promotion + truncate-to-safe (
raft_cb_become_leader,ha.c:917-919), pre-promotion eligibility fence (ha.c:933-951, bugs 848/849), advertise-real durable floor for freshness (raft_cb_vote_eligible,ha.c:299-360), and the contiguity guard refusing a forward gap (persist_wal_write_replicated,persist.c:691-706, bug-850). These remain; the library's(term,index)matching is strictly additive on top.
- R2/G5: heartbeat/replication never adopt a higher term (
Config replication seam (R7)
- Sources Consulted:
config_repl.c:941-979, 1001-1163, 1857-1944,ha.c:5338-5402, 4980-5036,ha_convergence.c:372-384. - Findings: config commits today through a separate
committed_config_tagindex (push_config_to_standbyswaits majority ACK thenatomic_store committed_config_tag,config_repl.c:965-970), carried on heartbeats (ha.c:5343-5345), with no term and tag-based anti-entropy — entirely outside the WAL(term,lsn)log. This is the second parallel violation. R7 replaces it: aWAL_CONFIG_MARKERentry (version_tag + sha256, not bytes) appended viaraft_log_append, ordered/committed/reconciled in the same(term,lsn)log; committed config derived from the latest committed marker within the committed prefix (replaces the separate index). Config bytes continue to travel via the existing out-of-band config-push transport (OPCode_replConfigPush), now keyed to the marker's{version_tag, sha256}and hash-verified against the committed marker before activation — never in the WAL record. (The reverted byte-delivery decision: the prior draft routed bytes through a full upgrade snapshot; that snapshot is descoped toconfig-upgrade-snapshot, so the byte transport reverts to the existing config-push path.)
Live regression infrastructure (R6)
- Sources Consulted:
src/controller/testsuite/waldump.{c,h},test.c:129-1760,.kiro/specs/ha-commit-durability/repro-851/,INVESTIGATION-failover-job-loss.md,test_commit_barrier_rollout.c,src/tools/deploy-ha.sh,test-commit-durability.sh,roadmap.md:55. - Findings: Infrastructure is ~90% present.
waldump_path()(waldump.h:42) already decodes per-entry(LSN, TERM, TYPE, PLEN, PCRC)and validates the CRC chain, with a payload-CRC fingerprint for cross-member comparison and nonzero exit on gaps/crc-break.xce_testscript primitives exist with exit-code verdicts:jobsub(captures$jobid),wait-primary,expect-jobs N,expect-job $jobid,ctrlstatus,waldump,!shellescape,$?/$jobidvars,g_session_failed→ process exit code (test.c:129-1760)./xce-deploy-ha+deploy-ha.sh/start.shlaunch a 3-node cluster;kill_node/start_node+ deterministic--wal-sync-ms 30000drive chaotic kills (test-commit-durability.sh:113-198).- Gaps to build: (a)
waldumpcurrently printsTERM=0for all entries because of G1 — once R1 lands it will show real terms; the R6 test must assert per-LSN term identity across members; (b) awalcompare <dirA> <dirB>cross-member(term,lsn,pcrc)divergence diff (already on the roadmap as planned tooling,roadmap.md:55) is the missing assertion primitive. R6 builds the chaotic-multi-kill script +walcompare, reusing all existing primitives.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
| Thin callback adapter (chosen) | Controller implements the upstream callback ports over WAL/repl/commit; library owns all protocol decisions | Honors the TLA-gap-analysis DECISION; one consistency domain; no protocol logic duplicated in controller; sim already proves the library | Requires deleting/neutralizing the term-blind controller logic (ha_compute_safe_committed_lsn, committed-boundary reconcile) rather than extending it |
Matches Ports-and-Adapters already established by the election callbacks |
| Patch the controller's split log (rejected) | Add (term,lsn) matching directly into ha_convergence.c + ha_compute_safe_committed_lsn |
No library dependency | Re-implements raft.tla in the controller; two consistency domains; cannot be sim-verified; explicitly rejected by the 2026-06-21 DECISION | Rejected |
| Config bytes in WAL (rejected) | Carry config bytes in WAL_CONFIG_MARKER payload |
Self-contained log | Bloats the log; R7.2/R7.4 forbid it | Rejected by requirements |
Design Decisions
Decision: Unify R1–R6 protocol onto the library; controller is a thin adapter
- Context: TLA-gap-analysis DECISION (2026-06-21) mandates the library own the protocol; the controller adapts.
- Alternatives Considered: (1) thin adapter (chosen); (2) patch controller split-log (rejected — duplicates protocol, unverifiable).
- Selected Approach: Wire five new library callbacks to controller I/O sites; route incoming heartbeat/replication through
raft_on_append_entries_request; drive append viaraft_log_append; replace the term-blind safe-commit and committed-boundary reconcile with the library'scommit_index/on_truncate_from. No protocol logic is added to the controller. - Rationale: Single consistency domain; the deterministic sim already verifies the protocol, so the live regression need only prove the wiring.
- Trade-offs: Must neutralize existing term-blind controller paths (a deletion, not an extension); upstream signature changes are revalidation triggers.
- Follow-up: Confirm the upstream callback/entry-point signatures are stable before wiring (Revalidation Triggers below).
Decision: Generalize job-state and config-change onto one (term,lsn) replicated log (R7)
- Context: Config is a second parallel consistency domain (
committed_config_tag, no term) exposed to the same divergence. - Selected Approach: Record each config change as a lightweight
WAL_CONFIG_MARKER(version_tag + sha256, no bytes) appended viaraft_log_appendso it inherits term-safety, reconcile, and commit. Derive committed config from the latest committed marker; retire the separatecommitted_config_tagcommit index. - Rationale: One log, one reconcile, one commit rule — the Generalization lens: config change is a special case of "an ordered committed log entry".
- Trade-offs: Config bytes are delivered out-of-band via the existing config-push transport (
OPCode_replConfigPush), hash-verified against the committed marker before activation — no snapshot dependency. During migration,committed_config_tagbecomes a derived value, not an authoritative index. - Follow-up: R7's marker is implementable now (no entity-bearing snapshots needed); the config bytes ride the existing config-push install path. The full materialized-state upgrade snapshot is descoped to
config-upgrade-snapshot— see the resolved decision below.
Decision (RESOLVED 2026-06-21): former R8 descoped into config-upgrade-snapshot; R7 reverts to out-of-band config-push byte delivery
- Context: The former Requirement 8 required a full entity-bearing state snapshot embedding the new config, installed on every member, so no member replays pre-upgrade WAL under the new config (the bug-857 hazard).
- Finding (the prior blocker): Snapshots are a 16-byte LSN sentinel (
persist.c:1068-1101/1280-1307); the entity-serialization machinery (xzarray_freeze/thaw) is dormant (kvmgr.c:297); roadmap "entity-bearing snapshots (Task 11)" is unstarted (roadmap.md:43). The full upgrade snapshot is therefore a HARD dependency on entity-bearing-snapshots, which does not exist. - Alternatives Considered:
- Make entity-bearing-snapshots a hard prerequisite and sequence the upgrade snapshot after it, inside this spec.
- Carry config bytes in the WAL marker — violates R7.2/R7.4.
- Per-node replay under new config — the exact bug-857 hazard.
- Descope the materialized-state-on-upgrade work into an independent spec (selected) — split the entity-snapshot blocker out of
ha-term-safetyso R1–R7 can ship now.
- Selected Approach: The configuration-upgrade materialized-state consistency (former R8) is descoped into the independent
config-upgrade-snapshotspec, which HARD-depends onentity-bearing-snapshots.ha-term-safetyretains R7's consensus half (the in-log(term,lsn)config marker) and reverts the configuration-byte delivery to the existing out-of-band config-push transport (OPCode_replConfigPush), keyed to the marker's{version_tag, sha256}and hash-verified against the committed marker before activation. No snapshot is built or depended on here.config-upgrade-snapshotlater supersedes this interim byte delivery for the upgrade case once entity-bearing snapshots land. - Rationale: Do not paper over an infeasible requirement, and do not block the implementable R1–R7 (protocol term-safety + config marker) on an unstarted entity-serialization backlog. The marker + commit + reconcile (R7) never required entities; only the materialized-state install did. The byte delivery falls back to the already-working config-push path.
- Trade-offs:
ha-term-safetyno longer delivers the full upgrade-snapshot guarantee (now owned byconfig-upgrade-snapshot); the interim config-push byte delivery is superseded for the upgrade case once that spec lands. - Follow-up:
config-upgrade-snapshotbrief + requirements written (../config-upgrade-snapshot/{brief.md,requirements.md}); it anchors on this spec's(term,lsn)config marker and consumesentity-bearing-snapshots.
Decision: Preserve the commit barrier + durability + clean-failover behaviors as policy
- Context: R4 and R5 must not regress ha-commit-durability (bugs 661/828) or bugs 848/849/850.
- Selected Approach:
on_commit_advancedonly triggers the existing synchronous barrier;majority_candidate_lockedself-cap, persist-before-ACK, the pre-promotion fence, advertise-real-durable-floor freshness, and the contiguity guard are unchanged. The library supplies the safe commit position; the durable act stays in the controller. - Rationale: The barrier/durability is verified by tests + the Protocol Verification Checklist (not by raft.tla, which assumes a durable log); changing source-of-truth for the commit position must not change the durability mechanism.
- Trade-offs: None functional; R4.x latency for the steady state is unchanged (R5.4) because the barrier path is untouched.
Risks & Mitigations
- Former R8 infeasible without entity-bearing snapshots → RESOLVED by descoping the configuration-upgrade materialized-state work into the independent
config-upgrade-snapshotspec (HARD-depends onentity-bearing-snapshots);ha-term-safetyships R1–R7 with out-of-band config-push byte delivery and no snapshot dependency. - Upstream signature drift (the three library specs are generated but not yet implemented) → Listed as Revalidation Triggers; the adapter wires to the exact published signatures; if a signature changes, re-check the adapter table.
- Neutralizing term-blind controller paths could regress clean failover → R5 acceptance criteria + the existing durability tests are the guard; the live R6 regression asserts both chaotic AND clean convergence.
waldumpshows TERM=0 pre-fix → expected (G1); R6 asserts non-zero, per-LSN-consistent terms post-fix, and the negative direction (pre-fix repro must fail) per R6.5.- Migration of
committed_config_tagto marker-derived → derive-not-store; on first boot after upgrade, a cluster with no marker treats config as term-0/lowest-known (R1.8 analogue) until the first marker commits.
References
.kiro/specs/ha-term-safety/requirements.md— R1–R7 (former R8 descoped 2026-06-21)..kiro/specs/config-upgrade-snapshot/{brief.md,requirements.md}— the descoped configuration-upgrade materialized-state snapshot (HARD-depends onentity-bearing-snapshots)..kiro/specs/ha-term-safety/TLA-gap-analysis.md— gaps G1–G6 + the library-owns-protocol DECISION..kiro/specs/raft-log-replication/design.md,.kiro/specs/raft-commit-safety/design.md,.kiro/specs/raft-sim-conformance/design.md— upstream callback contracts (consumed verbatim)..kiro/steering/persistence.md,ha-async-io.md,roadmap.md:13-21,43,55— durability policy, process-lifecycle invariant, Raft conformance series, Task 11 backlog, plannedwalcomparetooling..kiro/specs/ha-commit-durability/INVESTIGATION-failover-job-loss.md+repro-851/— bug-851/857 evidence and the regression baseline.