Commit graph

735 commits

Author SHA1 Message Date
8da8fd03dd fix(libxce): bug-1077 ctrlstatus crash from shared reply-union type confusion
ctx->reply.u is a single untagged union shared across opcodes (debugmsg /
get(dataset_t*) / jobid / clusterconf / ctrlstatus alias the same 8 bytes).
Commit 61e1391 added speculative free-guards
`if (ctx->reply.u.ctrlstatus) free_CtrlStatus_ptr(...)` (and the clusterconf
twin). A non-NULL slot does NOT imply a CtrlStatus*: a failover-aborted
reslist/joblist leaves ctx->reply.u.get == &getreply (a dead STACK pointer)
because xce_get_with_condition/xce_resval only cleared the slot on the success
path, not the xce_get_req<0 / cbuf_get<0 early returns. The next ctrlstatus
reply (worker thread) freed that foreign pointer as a CtrlStatus -> ASan
SEGV / stack-use-after-return in free_CtrlStatus (controller.capnp.c:11638).

Fix (root cause, both layers):
- Revert the unsafe free-guards in xce_handle_reply: MOVE_PTR overwrite only,
  never free/deref an untagged union slot by type.
- Clear ctx->reply.u.get = NULL on ALL exit paths of xce_get_with_condition
  and xce_resval via a single `goto done` cleanup (forget-proof; the bug was
  one forgotten failover path). Also drops the now-unused controller.capnp.h.
- Defense-in-depth: handle_get_reply drops a reply when the slot is NULL.

Regression: expose xce_handle_reply and add
test_handle_reply_ctrlstatus_does_not_free_foreign_slot, which poisons the
shared slot with a foreign heap value and proves the ctrlstatus reply handler
does not free it (RED reproduced the exact free_CtrlStatus_ptr double-free;
GREEN after fix). xcelib_test 244/0, controller_unit_test 166/0, all binaries
build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSxwb2Xt5F84ATgiAZnH8V
2026-06-24 09:44:43 +00:00
61e1391cd0 fix bug-1060 bug-1067 2026-06-24 17:05:43 +08:00
88823c59a7 test(ha-term-safety): harness post-failover client probes target a live node (bug-854 workaround)
The post-failover wait-primary/expect-job probes used the full masters list led
by the just-killed node, triggering libxce bug-854 (positional discovery can't
fail over from a dead-first master). Now they use a live-first list excluding the
killed primary.

NOTE: this exposes a deeper, separate post-failover AVAILABILITY issue (not data
loss): the new primary stays stuck in a catch-up loop with a lagging standby
(standby[0] lsn=5 < committed_lsn=6) and does not serve clients, so wait-primary
still times out. That is a catch-up/serving-readiness liveness concern orthogonal
to bug-828's committed-job DATA SAFETY, which is fixed and verified (walcompare
committed-prefix identical, jobs preserved on disk).
2026-06-23 04:12:32 +00:00
b77d412256 fix(ha-term-safety): bug-828 FIXED — remove premature pre-election WAL truncate (committed-job-loss on clean failover)
ROOT CAUSE (confirmed via live instrumentation — NOT a durability violation):
ha_enter_real_election truncated the WAL to last_known_committed_lsn BEFORE the
vote. That watermark lags the true commit point (the primary committed LSN 2-5 on
a durable majority then died before a heartbeat carried committed=5 to the
standby), so the cutoff was 1; persist_truncate_wal_above -> clamp_durable_lsn
lowered the standby's durable_lsn 5->1, physically destroying durably-committed
entries before the election even computed the safe point. The candidate then
advertised floor=1, safe_committed_lsn=1, and the post-election truncate kept only
LSN 1. This directly violated the documented rule: 'Never use last_known_committed
_lsn for truncation — it may be stale.'

FIX: delete the premature truncate. The candidate keeps its full durable log; the
ONLY truncates are now the authoritative votes-derived post-election
persist_truncate_wal_above(safe_committed_lsn) (raft_cb_become_leader) + the
library on_truncate_from (term,lsn) reconcile — both before any commit under the
new leader.

SAFE (reviewer-verified): a divergent uncommitted tail can only exist at a
stale/lower term (single-writer); the library's term-primary (lastLogTerm,
lastLogIndex) freshness gate denies such a candidate — correct now that task 3.4
fixed the term axis (the prerequisite). No divergent entry can win-and-commit; no
bug-339 election-loop. 177 unit tests + a RED-proven regression test.

LIVE: clean failover now preserves committed jobs — walcompare committed-prefix
identical across all 3 members, both jobs present on disk, across multiple runs.
(Residual harness EXIT 1 is the SEPARATE pre-existing libxce client bug-854:
client can't reach the live primary when the dead node is first in the masters
list — a client-discovery defect, orthogonal to server-side data safety.)
2026-06-23 04:06:18 +00:00
91b9a445f0 fix(ha-term-safety): bug-828 partial — revive dead safe-commit-from-votes + advertise real durable tip
Clean failover was truncating committed jobs because the post-election
safe-commit-from-votes computation was dead: vote_reply_dispatch decoded each
granting voter's advertised peerLastLogLsn but discarded it, so ha_compute_safe
_committed_lsn saw only the self-vote (the 'from 1 granted voters' fingerprint)
and truncated to self_lsn.

Part 1: vote_reply_dispatch now records the granting voter's (lsn,tag,grant) into
ctx->votes[] via a record-only ha_record_vote_reply seam (no double-promotion;
raft_on_vote_reply stays the sole production promotion driver) — so safe-commit
reflects the true voted majority.
Part 2: the candidate/voter advertise the real DURABLE (fsync'd) tip; persist_wal
_write_replicated advances durable_lsn after its fail-loud inline fdatasync.

Reviewer-verified SAFE (advertises only durable positions; safe-commit bounded by
the durable majority, preserving more committed entries, never marking a
non-majority-durable LSN safe). 177 unit tests; new test_ha_vote_gate + election
tests green.

PARTIAL: does NOT yet make the live clean-failover pass. A deeper slice of
bug-828/795 remains — a catch-up'd standby still under-advertises its durable
position in the live run (the documented architectural catch-up/durability gap),
so a job-less node can still win and truncate. Tracked for a dedicated effort.
2026-06-23 03:13:02 +00:00
9e5e293605 obs(ha-term-safety): promote committed_lsn advance to INFO + harness polls authoritative commit signal
The commit-engine committed_lsn advance was TRACE-only; promoted to INFO (fires only
on a real advance, not noisy) so operators/tests can observe the authoritative primary
committed watermark. The clean-failover harness now polls this 'commit: committed_lsn
N -> M' line (not the lagging ha_process_standby_state heartbeat) with generous catch-up
headroom.

This made the live RED->GREEN visible: with Option A, committed_lsn reaches 5 on a
stable cluster ('jobs committed on a majority (committed_lsn=5 >= 5)') even via catch-up
delivery — the commit-stall is fixed.
2026-06-23 00:28:14 +00:00
83fbed2370 fix(ha-term-safety): remove task-2.4 commit min-ceiling (Option A) + fix bug-991 catch-up off-by-one
Two coupled fixes for a live commit-stall (committed_lsn pinned at the own-term
NOOP while a 3-node cluster was durable to LSN 5 — jobs acked but never committed):

bug-991: the WAL catch-up applied_lsn was advanced to lsn+1 (next-to-apply) in
the three live/catch-up apply paths, but recovery/checkpoint/init and the leader's
catch-up streamer treat it as last-held. The over-advertise made the streamer skip
an LSN -> library logOk wedge -> no WalAck. Fixed to last-held (= lsn).

Option A (design reconsideration of REVIEW ISSUE 3): removed the
committed = min(durable-majority, raft_commit_index) ceiling. Under single-writer
the durable-majority watermark is already (term,lsn)-current-term-safe (term-carrying
replication + (term,lsn) reconcile/truncate-before-durable + own-term NOOP gate +
post-election truncate-to-safe), so the library commit_index was a redundant,
starvation-prone gate that pinned committed below the safe durable majority whenever
the library lagged. The durable-majority barrier + own-term writable gate are now
authoritative; raft_commit_index is inert-advisory.

Safety: reviewer-verified the durable-majority watermark is (term,lsn)-safe and
Figure-8 holds via the own-term gate (new RED->GREEN test); bug-851 cannot recur.
Live: commit trace 4->5 + walcompare committed-prefix identical across 3 nodes.
177 unit tests pass.
2026-06-23 00:21:06 +00:00
df4bc4f86a test(ha-term-safety): live-harness fixes + record bug-991 (commit stall via catch-up + 2.4 ceiling)
Ran clean_failover_preservation.sh on a real fs ($HOME/tmp/xce-ha-deploy, ext4).
PAYLOAD run CONFIRMED bug-990 fixed live: standbys persist the leader's real
term (2) for the committed prefix; walcompare OK.

Harness fixes: robust failover detection (any post-kill CTRL_PRIMARY promotion,
45s settle) + a wait-for-commit-before-kill gate (jobsub returns on accept not
commit, so the prior harness raced replication vs kill).

That commit-wait gate exposed bug-991: on a STABLE cluster, jobs replicate to
all members (durable LSN 5) but committed_lsn pins at 1. Catch-up-delivered
entries don't advance the library commit_index, so task-2.4's ceiling
min(durable-majority, raft_commit_index) starves commit below the durable
majority. A real regression from the commit-source switch (2.4+3.3), invisible
to the steady-state-only unit tests.
2026-06-22 21:10:48 +00:00
efe621c124 feat(ha-term-safety): retire term-0 repl_broadcast live send; carry leader term on replication (task 3.4)
Closes bug-990: the live replicator repl_broadcast wire-encoded term=0
(entry.term was never set), so standbys persisted hdr.term=0 and took the
legacy direct-apply path instead of the library (term,lsn) intake. Now the
live broadcast and catch-up streamer carry the leader's real term + AE prev
tuple; ha_apply_wal_catchup_entry routes every term-carrying entry through
raft_on_append_entries_request -> on_persist_entry (leader term stamped),
retiring the term-0 direct-apply. Standbys now persist the leader's real term
and reconcile by (term,lsn); the live commit chain is end-to-end.

848/849/850 + persist-before-ACK + commit-barrier + bug-960 preserved.
Proven at integration level; the definitive live STRICT_TERM failover run is
env-blocked here (bug-924 overlayfs) and recommended on a real fs.
2026-06-22 15:45:14 +00:00
fda15336d5 test(ha-term-safety): clean-failover live harness; mark 5.2/5.4/5.5 blocked on bug-990 (live-send retirement)
Task 5.2 harness deploys a real 3-node localhost cluster, kills the primary,
promotes a standby, restarts the old primary, and asserts convergence via
walcompare --committed + per-job expect-job. Runs GREEN in PAYLOAD mode
(guards 848/849/850 + no committed-job loss).

The term-identity half of R5.1 is BLOCKED (bug-990): live replication still
uses term-0 repl_broadcast; the library send loop (on_send_append_entries) has
no live caller, so standbys persist hdr.term=0. The live-send retirement was
deferred across tasks 3.2/3.3 into verification tasks 5.2/5.4 that cannot
contain the production cutover. Needs a dedicated implementation task.
2026-06-22 13:27:21 +00:00
a166ecb45b test(ha-term-safety): cross-member walcompare divergence tool (task 5.3)
Diffs member WALs by (lsn -> term, pcrc) over the committed prefix, exits non-zero
on any committed-prefix divergence with the diverging LSN + per-member entry
identities. Reuses the canonical waldump decoder via a new per-entry visitor seam
(no second WAL parser). Detects same-LSN/diff-term and same-LSN/diff-content
divergences (the bug-851 class) that an LSN-range check would mask.
2026-06-22 13:05:23 +00:00
d39500ba48 test(ha-term-safety): end-to-end adapter callback + marker-path integration test (task 5.1) 2026-06-22 12:03:20 +00:00
be4c1f0721 feat(ha-term-safety): out-of-band config bytes, hash-verify-before-activate, serve-fence (task 4.3)
Wires config_committed_marker to a production consumer (config_activate_committed):
verifies pushed bytes' sha256 against the committed marker before activation, fences
(loud, non-serving, stay-alive) on mismatch, and activates the latest committed marker
on recovery. Enforced via ha_dispatch_gate + promotion gate.

Fixes bug-981: a 2nd+ config upgrade could permanently fence a standby on a
push-before-commit ordering. Adds an off-iomgr delaymgr retry scheduled from the
commit-advance hook (atomic read + non-blocking enqueue only on the ACK thread) that
re-activates and clears the stale fence once the new marker commits and bytes verify.
2026-06-22 11:47:15 +00:00
c3201a2b1e feat(ha-term-safety): append config markers via unified log + derive committed config (task 4.2)
Wires do_upgrade_config_with_bytes to append WAL_CONFIG_MARKER through
raft_log_append (live term-stamped consensus record), demotes committed_config_tag
to a non-authoritative cache, and derives the committed config from the latest
committed marker. Preserves the derive across checkpoint compaction via an optional,
backward-compatible config-ref tail in the snapshot sentinel.
2026-06-22 10:34:10 +00:00
f447ad4183 feat(ha-term-safety): add WAL_CONFIG_MARKER entry type + reference payload codec (task 4.1) 2026-06-22 09:37:23 +00:00
f1783f7952 feat(ha-term-safety): drive ACK path through raft_on_append_entries_reply + advance_commit (task 3.3)
Includes fix for bug-960: endpoint-type-aware peer-index resolution
(promoted-vote relative vs heartbeat-standby absolute) with fail-loud
bounds check, preventing an out-of-bounds match_index[] write / wrong-peer
false-quorum on the replication ACK path.
2026-06-22 09:25:44 +00:00
754e586f8f feat(ha-term-safety): route AppendEntries intake through library + leaderCommit wire (task 3.2) 2026-06-22 08:42:35 +00:00
6a519a928b feat(ha-term-safety): drive raft_log_append on primary append path (task 3.1) 2026-06-22 08:09:47 +00:00
3dd8493fff feat(ha-term-safety): wire five callbacks + library init restore (task 2.5) 2026-06-22 07:47:30 +00:00
3470bdfb71 feat(ha-term-safety): on_commit_advanced barrier trigger + commit-gate ownership (task 2.4) 2026-06-22 07:33:04 +00:00
2c5d298cb5 feat(ha-term-safety): on_send_append_entries callback body (task 2.3)
Replaces the stub: builds a WalCatchupEntry AppendEntries to standbys[peer_idx].ep
carrying prev_log_index/prev_log_term; for n_entries==1 sources the entry at
prev_log_index+1 from the local WAL (term via persist_entry_term, type+payload via
ha_read_wal_entry_at); n_entries==0 emits a zero-entry heartbeat. Async send
(xce_wal_batch_req_raw), payload freed on all paths. 2 tests via a capture seam.

Review-applied: corrected the misleading repl.c comment — leader_commit is accepted
but NOT yet wire-encoded (deferred to task 3.2: WalCatchupEntry leaderCommit field +
standby intake). Reviewer APPROVED; per-group 0 failures.
2026-06-22 06:01:30 +00:00
ed2443533f feat(ha-term-safety): on_get_entry_term + on_truncate_from callback bodies (task 2.2)
on_get_entry_term → persist_entry_term (recorded term, 0=absent, legacy-0→1; caveat
documented per 1.3 review). on_truncate_from → persist_truncate_wal_above(p, index-1)
(delete LSN>=index), returns rc for library/leader retry; non-promotable fence intact;
replaces the committed-boundary heuristic on the library-driven path. New
truncate_term_index_locked keeps the in-memory per-LSN term index consistent on both
truncate paths (under lock, cutoff-aligned) → no stale-term read after truncation.
3 tests, reviewer APPROVED, per-group 0 failures.
2026-06-22 05:29:06 +00:00
8f3d3ce8f4 feat(ha-term-safety): on_persist_entry callback body (task 2.1)
Replaces the iter-1 stub: persist_set_term(p, term) stamps the leader/library term
into hdr.term, then role-routes the write — primary via persist_wal_async, standby
via persist_wal_write_replicated(p, index, ...). Persist failure returns non-zero
(no false ACK; durable_lsn unchanged); bug-850 contiguity guard + persist-before-ACK
preserved beneath. In-flight raft_entry_blob_t {type,payload_len} envelope added to
wal_types.h (never enters the 40-byte header). 3 tests, reviewer APPROVED.

Deferred (recorded in tasks.md): task 3.1 must retire the hand-rolled primary append
so current_lsn tracks the library index; task 3.2 must not call on_persist_entry on
heartbeats. Per-group 0 failures (pre-existing test_commit_barrier_reply leak aside).
2026-06-22 04:53:18 +00:00
d540ebd183 feat(ha-term-safety): WAL term seam + recovery read-back + legacy-0 rule (task 1.3)
persist_set_term sets the live election term so appends stamp hdr.term (fixes the
G1 term-0 stamping); replay_wal builds an in-memory per-LSN term index (freed in
persist_fini, no on-disk change); persist_entry_term reads it back (0=absent).
Legacy-0 -> WAL_LEGACY_TERM=1 at read-back only (raw 0 retained so present-legacy-0
is distinguishable from absent). 40-byte wal_entry_hdr_t preserved (value fix, R1.7).
5 new tests (test_term_safety_adapter.c); reviewer APPROVED. Feeds task 2.2.

All controller test groups report 0 failures; the meson full-suite timeout is the
bug-924 overlayfs /tmp fdatasync artifact, not this task.
2026-06-22 04:32:29 +00:00
a95d57f733 chore(nix): repin flake.lock (c-capnproto) to restore buildable dev shell
Resolves bug-918: the c-capnproto input was pinned to a mutable main.tar.gz whose
upstream hash drifted, breaking fresh nix develop shells. Refreshed lock (capnproto
1.2.0) — fresh builds and worktree isolation work again.
2026-06-22 04:32:14 +00:00
004a40bb3b feat(ha-term-safety): restore controller build + wire vote/AE term wire fields (tasks 1.1, 1.2)
1.1: WalCatchupEntry gains term@4/prevLogIndex@5/prevLogTerm@6 (append-only).
1.2: RequestVote/PreVote gain lastLogTerm@4/lastLogIndex@5; cluster.c vote callers
fixed to the extended library arity (RESTORES xce_controller + controller_unit_test
compile+link, broken by the raft-commit-safety signature extension); ha.c emits the
candidate's last-log tuple on send.

5 minimal stub log/commit callbacks wired into s_real_cbs so raft_init's new
non-NULL requirement is satisfied — tasks 2.x replace them with real bodies; the
logOk freshness gate is inert until the term seam (1.3) + on_get_entry_term (2.2).

Pre-existing test_repl_unit.c wal_catchup -EAGAIN failures + ASAN leaks are an
overlayfs /tmp fsync env artifact (bug-850), not from this task (untouched paths;
append-only schema cannot affect the @3 entries-list decode). Build/baseline
isolation is blocked by a c-capnproto flake-hash drift — see Implementation Notes.
2026-06-22 03:41:49 +00:00
62a927b419 test(raft-commit-safety): simulator invariant tests (tasks 7.1-7.4)
7.1 Figure-8 commit refusal (G4): prior-term entry at a genuine quorum HELD (only the
   current-term gate keeps commit_index at 0), then a current-term entry advances over
   both (indirect commit) with one fire. Closes the Req 6.3 two-phase coverage.
7.2 Leader Completeness: committed prefix on {0,1}; node 0 fails; the stale node 2 is
   DENIED by the freshness gate so only the up-to-date node 1 can win — and it holds the
   full committed prefix (term+bytes).
7.3 freshness lexicographic (vote + prevote): stale-term-higher-index DENIED,
   fresher-term-lower-index GRANTED, tie decided by index; prevote side-effect-free.
7.4 on_commit_advanced trigger: fire-count tracks STRICT advances — held(0)/advance(1)/
   no-op-redrive(1)/advance(2)/non-leader(2).

Test-only (sim_set_log_entry local helper); reviewed in-parent (transient API 529 on the
review subagent). ASAN green (243 tests).
2026-06-22 00:41:11 +00:00
56485e2fa3 feat(raft-commit-safety): (lastLogTerm,lastLogIndex) vote+prevote freshness (tasks 1.3, 4.1-4.2, 5.1-5.2, 6.2)
logOk (raft.tla:285, closes G6): local_last_log_term = log_len>0 ? on_get_entry_term(log_len):0;
logOk = cand_last_log_term > local || (== && cand_last_log_index >= log_len). Lexicographic:
term dominates, tie decided by index. Shared raft_log_ok helper.

Vote (4.2): logOk deny inserted after stale-term/UpdateTerm/role-gate/one-vote-per-term and
BEFORE on_vote_eligible (denies even when the app gate would grant); persist-before-grant intact.
PreVote (5.2): identical logOk, fully side-effect-free (no term/vote/role/commit mutation, no
on_persist). Signatures extended (1.3) on raft_on_vote_request/pre_vote_request; all 39 lib call
sites + sim routing updated; sim captures the candidate's send-time last-log tuple (raft_sim_rpc_t).
Empty-log call sites pass (0,0) → logOk true → existing outcomes preserved. 9 freshness tests.

NOTE: controller cluster.c vote callers still use the old signatures and will NOT compile — an
accepted, design-documented revalidation trigger; the controller adapter plumbing is owned by the
downstream ha-term-safety spec (to be implemented next). Library suite ASAN green (239 tests).
2026-06-22 00:14:26 +00:00
195f660f9a feat(raft-commit-safety): raft_advance_commit Figure-8 current-term rule (tasks 3.1-3.3, 6.1)
AdvanceCommitIndex (raft.tla:259/270, closes G4): leader-only (-EPERM); Max(agreeIndexes)
= highest N with a quorum ((n_peers/2)+1) of match_index>=N, self counted as log_len;
scan log_len downward. CURRENT-TERM RULE: advance commit_index to N only when
on_get_entry_term(N)==s->term, else HOLD (Figure-8 safety — never commit a previous-term
entry on replication count alone; a later current-term commit indirectly commits it).
Monotonic; fires on_commit_advanced once per strict advance; commit_index<=log_len.

3.3: raft_on_append_entries_reply left untouched — verified it never advances commit
(separation, Req 2.6). 6.1: sim commit-driver helper raft_sim_advance_commit added.

Req 6.3 two-phase indirect-commit transition covered end-to-end by task 7.1 (sim).
ASAN green (230 tests).
2026-06-21 23:27:12 +00:00
3ba6f03a9e feat(raft-commit-safety): commit_index state + on_commit_advanced callback + init (tasks 1.1, 1.2, 2.1)
1.1: raft_state_t.commit_index (monotonic, ==WAL LSN, <=log_len, advanced only via
raft_advance_commit, untouched by AE reply) + raft_config_t.initial_commit_index.
1.2: on_commit_advanced(ctx, commit_index) REQUIRED callback — fired only on strict
advance, a TRIGGER for the controller commit barrier (never does durability itself).
2.1: raft_init requires it (-EINVAL if NULL) + restores commit_index from
initial_commit_index. raft_advance_commit prototype declared (impl in 3.2).

Coupled callback wiring to keep the required-callback ripple green: sim wires
sim_on_commit_advanced (+per-node commit_index mirror & fire-count), all test cbs
builders set it, raft_sim_restart_node restores the durable commit position. Vote
signatures unchanged; no commit-advance logic yet. ASAN green (224 tests).
2026-06-21 23:11:17 +00:00
3d6dc9cc56 test(raft-log-replication): close Req 8.3 node-restart with sim restart primitive
raft_sim_restart_node models a process restart: the durable in-memory log[]
(WAL) and persisted (term, voted_for) survive, while volatile consensus state
is cleared by re-running raft_init (returns the node to FOLLOWER). Callbacks,
ctx, idx, and partition flag are preserved.

test_sim_9_5_node_restart_resumes_replication restarts a follower mid-replication
(partial 2-of-3 log), asserts the log survived (load-bearing: log_count==2 would
fail if wiped), role/term restored, then replication resumes and converges with
Log Matching. Closes the 8.3 'node restart while replication proceeds' sub-clause
(8/8 requirement sections now fully covered). ASAN green (220 tests).
2026-06-21 21:49:20 +00:00
9c1a034aa8 test(raft-log-replication): simulator invariant tests (tasks 9.1-9.4)
9.1 replication + partition catch-up: 3-entry quorum replication then Log Matching;
   isolate a follower, heal, assert next_index walks back, catch-up, Log Matching.
9.2 divergent-suffix truncation (G3): follower with a same-index/different-term tail
   converges via conflict-truncate; byte-for-byte prefix equality + Log Matching.
9.3 drop/dup/reorder convergence: dropped AE recovered; duplicate already-have AE is
   a no-op; genuine out-of-order delivery (entry-3 AE before entry-2) rejected via the
   logOk gap with no phantom gap; converges + Log Matching (Req 8.3).
9.4 chaotic-churn G5/bug-851 regression: stale-term node rejoins under a higher-term
   leader, adopts the term via the AE/heartbeat UpdateTerm path (load-bearing: would
   fail as stale-reject if UpdateTerm-on-AE regressed), voted_for cleared, no second
   leader, and a post-converge election campaigns strictly above the old term (cannot
   reuse). Log Matching holds throughout.

Review-applied: genuine reorder in 9.3; active re-election + voted_for assertions in
9.4. ASAN green (219 tests).
2026-06-21 17:06:05 +00:00
6ff07e5843 feat(raft-log-replication): Log Matching assertion helper (task 8.3)
raft_sim_check_log_matching: for every node pair and every shared index i, if
both entries at i have equal term, assert the whole prefix 1..i is term-and-byte
identical (Req 8.4, raft.tla Log Matching). Read-only, framework-agnostic (returns
1/0), bounded. Self-tests: pass on a converged cluster, fail on a hand-divergent
pair sharing (i=2,term=7) but differing at the index-1 prefix. ASAN green.
2026-06-21 16:47:49 +00:00
0e8c6ed05a feat(raft-log-replication): simulator AppendEntries delivery (task 8.2)
raft_sim_deliver_append_entries drains the AE queue, routes each buffered AE to
the (non-partitioned) destination via raft_on_append_entries_request, and routes
the result back to the source via raft_on_append_entries_reply — mirroring the
vote-RPC routing; partitioned endpoints are skipped.

Review-applied (HIGH): raft_sim_ae_t now captures the leader's SEND-time term
(leader_term) at buffer time and delivery uses it instead of the live source
state.term — without this, reorder/churn delivery (9.3/9.4) would inject the
wrong term and defeat UpdateTerm/stale-drop (G5). Smoke test asserts replication
to a connected follower + isolation of a partitioned one. ASAN green.
2026-06-21 16:40:11 +00:00
eeb95591ae feat(raft-log-replication): raft_on_append_entries_reply (tasks 7.1, 7.2)
HandleAppendEntriesResponse (raft.tla:393): stale-term drop first (applied only
when reply_term==s->term, raft.tla:415, Req 5.4 — both lower and higher dropped);
success -> next_index[peer]=match_index+1, match_index[peer]=match_index (Req
5.1-5.2); failure -> next_index[peer]=Max(next-1,1) floored, match_index unchanged
(Req 5.3); no commit advance. uint64 underflow blocked by the >1 floor guard.
3 tests incl. floor-at-1 and lower/higher stale. ASAN green.
2026-06-21 16:26:42 +00:00
3c5185a5c3 feat(raft-log-replication): raft_on_append_entries_request UpdateTerm+logOk+accept (tasks 5.1-5.2, 6.1-6.3)
Complete HandleAppendEntriesRequest (raft.tla:327) + UpdateTerm (raft.tla:406),
implemented as one function to avoid a misleading partial handler. Fixed step
order: UpdateTerm FIRST on every RPC incl. heartbeat (G5, strictly-greater,
clears voted_for, persists, leader step-down fires on_become_follower) ->
stale reject -> candidate step-down -> logOk (G2) -> reject if !logOk ->
accept (already-have / conflict-truncate-then-repersist (G3) / append).
Never advances commit, never does transport I/O.

Review-applied: logOk now includes the explicit prev_log_index <= log_len bound
(raft.tla:330 / Req 4.1) instead of relying solely on the 0-absent sentinel;
added a candidate-equal-term-logOk-fails step-ordering test. 15 tests, ASAN green.
2026-06-21 16:18:02 +00:00
a1aacc0389 feat(raft-log-replication): raft_send_append_entries AppendEntries emit (tasks 4.1, 4.2)
Leader-only emit (raft.tla AppendEntries:204, Req 3.5 — no-op off leader);
prev_log_index=next_index[j]-1, prev_log_term via on_get_entry_term (0/unqueried
at index 0, Req 3.2-3.3); n_entries=1 when next_index[j]<=log_len else 0 heartbeat
(Req 3.4); leader_commit opaque passthrough. Trace fires only on successful send
(consistency with raft_log_append) and non-leader RAFT_OK asserted — both from
review. 4 spy-based tests. ASAN green.
2026-06-21 16:01:17 +00:00
e5b9b83955 feat(raft-log-replication): raft_log_append leader client-entry append (tasks 3.1, 3.2)
Leader-only (-EPERM for follower/candidate, no mutation/callback); index=
log_len+1, term=currentTerm (raft.tla ClientRequest:246, Req 2.1-2.4);
persist-before-act: on_persist_entry called once BEFORE log_len advances,
non-zero return propagated with no phantom entry (Req 2.5); no commit-index
advance, no transport I/O. Spy-based tests for all four branches. ASAN green.
2026-06-21 15:50:33 +00:00
fc87a662cd fix(raft): reject n_peers > RAFT_MAX_PEERS in raft_init
next_index[]/match_index[]/pre_vote_replies[] are fixed-size RAFT_MAX_PEERS
arrays indexed by peer; without an upper-bound check a caller passing
n_peers > 8 would OOB-write in the leader-progress and pre-vote loops.
Add the guard (returns -EINVAL, no mutation) + canary-checked test.

Latent since task 1.1; surfaced by task 2.2 review (first loop writes into
the new arrays). No current caller exceeds the bound (sim caps at 8, HA <= 4).
2026-06-21 15:42:37 +00:00
6ea89569fa feat(raft-log-replication): init leader progress on becoming leader (task 2.2)
next_index[j]=log_len+1, match_index[j]=0 for every peer at both win paths
(single-node RAFT_ELECTED_IMMEDIATELY in raft_start_election; multi-node
RAFT_BECAME_LEADER in raft_on_vote_reply) via shared raft_init_leader_progress
helper (raft.tla BecomeLeader, Req 3.1). Suite green under ASAN.
2026-06-21 15:40:26 +00:00
d70f2507c3 feat(raft-log-replication): require 4 log callbacks in raft_init + sim log backing (tasks 2.1, 8.1)
2.1: raft_init returns -EINVAL when any of on_persist_entry/on_get_entry_term/
on_send_append_entries/on_truncate_from is NULL (Req 7.1-7.4), before any write
to *s; restores s->log_len = cfg->initial_log_len (Req 1.1).

8.1: raft_sim gains a per-node in-memory log (term+bytes per 1-based index) and
an AE buffer queue; wires all four callbacks (persist/get-term/truncate/send).
Pulled forward to land with 2.1 so the stricter init does not leave the
sim/proto suite red between tasks. AE delivery deferred to 8.2.

Suite green under ASAN (Ok:1 Fail:0).
2026-06-21 15:30:09 +00:00
7b6cbb8e3e fix(test): sync test_config DEFAULT_SYNC_TIMEOUT_MS assertion to 500
Commit ca1d9ae raised the commit-barrier default 50->500ms in config.h
but left test_config.c asserting ==50, leaving test_persistence_constants
failing on HEAD. Sync the assertion to the committed constant.

Unrelated to raft-log-replication; fixed in isolation so the library
suite has a clean baseline for the spec's task-10.1 green gate.
2026-06-21 15:18:39 +00:00
6b5b201a64 feat(raft-log-replication): header contract — log model, callbacks, entry points (tasks 1.1-1.3)
Extend src/lib/raft/raft.h for the TLA+ log half (raft.tla logVars/leaderVars):
- raft_state_t: log_len, next_index[], match_index[] (no entry bytes stored —
  terms via on_get_entry_term, bytes in the adapter); raft_config_t.initial_log_len.
- raft_callbacks_t: 4 REQUIRED log callbacks on_persist_entry, on_get_entry_term,
  on_send_append_entries, on_truncate_from.
- Entry points + raft_append_result_t: raft_log_append (ClientRequest),
  raft_send_append_entries (AppendEntries emit), raft_on_append_entries_request
  (Handle + UpdateTerm-first), raft_on_append_entries_reply.

Concretized on_send_append_entries's varargs "entries handle" placeholder to an
explicit n_entries (0/1); design.md updated to match. Header compiles clean with
and without XCE_TESTING (-Wall -Wextra -fsyntax-only).
2026-06-21 14:15:15 +00:00
20e59d0b42 checkpoint: bug-851 root cause + raft TLA+ conformance spec family
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.
2026-06-21 14:02:15 +00:00
3b4656228e test(ha-heartbeat-committed-propagation): verify vote floor consumes heartbeat-raised committed safely (task 3.2)
Test-only: exercises the real ha_vote_self_freshness/handle_request_vote gate.
A heartbeat raise via ha_observe_heartbeat_committed lifts the floor to
max(durable=5, raised=9); candidate at 7 denied, at 9/12 granted; a lower
heartbeat never lowers it. Discriminating constants fail under either regression
(floor->durable-only or fold->min). No production/vote-path change (Req 3.2).
123/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:31:04 +00:00
d534f83e46 feat(ha-heartbeat-committed-propagation): stamp own committed+term on primary heartbeat (task 3.1)
primary_heartbeat_fn now sends xce_ha_heartbeat_req with the primary's OWN
atomic ha->term / persist.committed_lsn / committed_config_tag (NOT last_known_*),
snapshotted once per tick (Req 1.1, 3.4). XCE_TESTING seam adds a capturing
ha_heartbeat_req_hook (precedence new->legacy->real) so existing ha_nop_req_hook
liveness tests stay green; send-failure WARN + last_push_time unchanged. New test
poisons last_known_* to prove own-committed. Direct binary 119/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:22:50 +00:00
90e7179a89 feat(ha-heartbeat-committed-propagation): standby OP_nop consume branch + term-guard (task 2.2)
Append a committed-consume branch to the OP_nop handler (existing liveness/reply
byte-unchanged): ha_consume_heartbeat_committed applies the non-primary gate
(3.4) + stale-term guard (accept hb->term >= ha->term, 3.2), then delegates to
the monotonic ha_observe_heartbeat_committed (1.2). Absent body / zero / failed
gate are no-ops (4.2). No vote-floor edit — safety is the term-guard. 4 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:02:15 +00:00
8bfa861be2 feat(ha-heartbeat-committed-propagation): add reconcile-free ha_observe_heartbeat_committed (task 2.1)
Strict-monotonic updater for a standby's learned committed position: advance
last_known_committed_lsn only when strictly greater (zero/lower no-op), set the
config tag together (tag-before-lsn release ordering for reader safety). No
ha_rejoin_reconcile, no truncation, no other mutation (Req 2.1-2.3, 3.1, 3.3).
4 revert-detecting unit tests; controller suite 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:52:18 +00:00
282a82ff7e feat(ha-heartbeat-committed-propagation): add xce_ha_heartbeat_req sender (task 1.2)
Send the HA heartbeat as OP_nop + the existing HeartbeatMsg body carrying
{term, committed_lsn, committed_config_tag} (senderPriority unused). Generic
xce_nop_req untouched; C-side body support pre-existed (no typemap edit). Req 1.1
producer half. Round-trip test proves the 3 fields survive the wire; lib suite
0 failures under ASAN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:40:31 +00:00
f4327ce4f6 feat(ha-heartbeat-committed-propagation): migrate last_known_committed_config_tag to _Atomic (task 1.1)
Mirror the sibling last_known_committed_lsn: all writes release, reads acquire.
Prereq for a heartbeat-receive writer + the ctrlstatus reader to interact
safely (Req 2.3). Pure access migration, no behavior change; controller suite
0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:27:46 +00:00