myxce/.kiro/specs/ha-consistency-invariants/research.md
Rongsong Shen 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

40 KiB
Raw Blame History

Gap Analysis: ha-consistency-invariants

Date: 2026-06-10. Sources: discovery audit (brief.md), code-explorer verification pass (persist.c, repl.c, ha.c, job.c, cluster.c, config_repl.c, ha-wal-replication tasks.md).

1. Current State Investigation

Existing assets directly reusable

Asset Location Relevance
Per-standby acked LSN (≈ Raft matchIndex) standby_conn_t.acked_lsn (repl.h), updated by repl_on_ack (repl.c:187) Already exists — the bookkeeping needed for honest per-entry commit verification is in place, just unused by the wait logic
Per-standby join/applied LSN (≈ nextIndex) standby_conn_t.join_applied_lsn Catch-up positioning
Sync commit barrier (job submit only) persist_wal_sync_append + repl_wait_majority_ack (persist.c:1003-1049, repl.c:386-461) The pattern to generalize; pthread condvar wait (ack_cond) signaled by repl_on_ack
Full fan-out broadcast repl_broadcast (repl.c:210-282) Req 6.1 already satisfied
WAL streaming catch-up + snapshot fallback ha_stream_wal_to_standby (config_repl.c:1450+), snapshot transfer Req 6.3 satisfied; drive mechanism needs change for 6.2
Hook-based persistence dispatch persist_job_hook_fun / persist_queue_hook_fun / persist_worker_hook_fun (persist.c:1107/1199/1241) on HOOK_AFTER_EVENT The single seam through which all state-changing writes flow
Quorum gate at dispatch ha_has_write_quorum + ha_dispatch_gate (ha.c:1507-1612) Req 7.1 partially satisfied (gate-time only)
ha-wal-replication ACK machinery tasks 1.1–5.5 complete; only 5.4 (live 3-node integration test) pending Upstream dependency effectively satisfied for design

Verified behaviors (with evidence)

  1. Apply-before-commit is universal — including job submit. job_submit() (job.c:518) inserts the job into the in-memory table, THEN xce_run_hooks(HOOK_AFTER_EVENT) (job.c:553) fires the WAL write + majority wait. For state transitions, mark_job_start/finish/... mutate job->job.state between HOOK_BEFORE_EVENT and HOOK_AFTER_EVENT (e.g., job.c:585-604). Queue and worker state likewise mutate before their hooks fire. On every path, in-memory state precedes the log entry; what differs is only whether the reply waits (job submit: yes; everything else: no). Req 2.4 (apply only after commit) is violated everywhere today.
  2. Majority-ACK verification is not per-entry. repl_wait_majority_ack (repl.c:408-443) counts standbys with acked_lsn > 0 — i.e., "ACKed anything, ever" — not acked_lsn >= this_entry_lsn. Once a standby ACKs once, it counts toward every subsequent quorum until disconnect. Concurrent sync appends share one global ack_cond and would cross-satisfy each other. Req 4.2 needs per-LSN comparison; the acked_lsn field already supports it.
  3. Threading is permissive but finite. (Corrected 2026-06-11; original pass understated the pool and overstated the risk.) The sync wait is pthread_cond_timedwait on a vtask pool thread — default 8 threads, --vcpus CLI override (cluster.c:1082). Job submit already blocks one. Generalizing to all write types multiplies concurrent blocked workers; 8 simultaneous slow commits would stall the vtask pool. This is a throughput risk, not a liveness risk: WAL ACKs are decoded and waiters signaled on the iomgr thread (peer_ctrl_msg_handler → repl_on_ack, ha.c:2770-2792), so a fully blocked pool still receives its wakeups — requests queue until a commit completes or times out, but no deadlock is possible. Steering allows vtask handlers to block briefly ("return quickly"; the iomgr never-block rule doesn't apply). Mitigation inputs in Research Needed item 3 (commit-wait admission cap; async continuation as the durable fix).
  4. Job state transitions are reported facts, not requests. on_job_start/finish/fail arrive via worker notifications (do_notify, cluster.c:118-156) with no client awaiting a reply. "Rejecting" a job-finish on commit timeout cannot un-finish the job on the worker; the entry must instead be held/retried until commit. The rejection semantics in Req 2.2 map cleanly to request-shaped operations (submit, queue ctrl, config) but need a hold-and-retry interpretation for notification-shaped facts.
  5. COMMITTING removal is concretely mapped. raft_cb_become_leader (ha.c:505) sets COMMITTING; removal means: set CTRL_PRIMARY directly, move hook registration from ha_promote_to_primary (ha.c:1691-1698) into the become-leader path, delete the committing watchdog (ha.c:593-616, 3082-3125), make ha_check_majority_convergence (ha.c:1313) and repl_ack_count/ha_repl_ack_received obsolete, adjust the COMMITTING guard in the FSM path (ha.c:2967) and the dispatch-gate COMMITTING branch (ha.c:1594-1612).
  6. Catch-up is heartbeat-polled. ha_stream_wal_to_standby sends all batches [standby_lsn+1, committed_lsn] in one call without per-batch ACK waits, but the NEXT round is only triggered by the next heartbeat reply (3s cadence) carrying an updated applied_lsn (ha.c:2734 → 1467). Req 6.2 needs ACK-driven (or WAL-append-driven) continuation.

2. Requirement-to-Asset Map

Req Existing asset Gap Tag
1.1-1.2 (direct PRIMARY, single role) become_leader callback Remove COMMITTING state + watchdog; move hook registration Missing (mapped)
1.3 (no redirect from primary) ha_should_redirect (ha.c:1655) Returns redirect in COMMITTING; resolved by state removal Missing
1.5 / 3.3 (no timeout bypassing majority) — committing_timeout_fn force-promote must be deleted Missing
2.1 (majority barrier all ops) sync path for WAL_JOB_SUBMIT Generalize to JOB_* transitions, QUEUE_CTRL, WORKER_CTRL (persist.c:1169/1199/1241) Missing
2.2 (reject on timeout) job submit error propagation (job.c:563-570) Worker notify is pure fire-and-forget (no reply read, no retry, no rejoin resync) — controller MUST own durability via a pending-facts queue; rejection = permanent loss (already true today). See deep-dive §A Missing (resolved — constraint identified)
2.4 (provisional state, commit-gated egress) Memory already rebuilt from checkpoint+WAL on restart (persist_recover); role-agnostic replay fns DECIDED (2026-06-10): provisional-state model — mutation order unchanged; gate requester confirmation + irrevocable-action egress (dataex/scheduler export filters provisional entities by commit status); makes §C reconciliation mandatory core. See deep-dive §B Missing (decided — egress gating + tagging)
2.5 (single outcome for timed-out entries) WAL truncation on promotion (ha.c:471) + on standby RDHUP (ha.c:3419); safe_committed_lsn majority-median derivation (ha.c:373-432) FIVE convergence gaps: no truncation on demotion, no in-memory phantom reconciliation, snapshot is LSN-sentinel only (no entity state), committed_lsn not persisted (restart replays uncommitted suffix), no conflict detection in apply. See deep-dive §C Missing (resolved — 5 gaps enumerated)
3.1-3.2 (own-term commit) — No own-term tracking on promotion; no-op entry vs first-write decision Missing
4.1 (count only reachable) ha_has_write_quorum (ha.c:1507) Excludes neither catch-up nor per-write reachability Missing
4.2-4.4 (ACK-time per-entry verification) acked_lsn per standby Wait logic compares > 0 not >= entry_lsn; no per-entry wait slots Missing
5.1-5.3 (config barrier) push_config_to_standbys + repl_wait_majority_ack variant (config_repl.c:916-928) Return code ignored by caller (config_repl.c:1057); error propagation Missing (small)
6.1 (full fan-out) repl_broadcast None Done
6.2 (leader-driven catch-up) ha_stream_wal_to_standby Continuation is heartbeat-polled, not ACK-driven Missing
6.3-6.4 (snapshot fallback, re-include) snapshot transfer, repl_complete_catchup None Done
6.5 (lag doesn't block confirmation) majority threshold Preserved by design Done
7.1 (reject on quorum loss) dispatch gate Gate-time only; ACK-time enforcement closes the TOCTOU Partial
7.2 (log quorum transitions) — Add observability logging Missing (small)
7.4 (auto-resume) gate re-evaluates per request None expected Done (verify)
7.5 (standalone self-quorum) n_peers <= 1 paths Preserve unchanged Constraint

3. Implementation Approach Options

Option A: Extend in place

Generalize persist_wal_sync_append to all WAL types at the existing hook sites; fix repl_wait_majority_ack to per-LSN comparison; delete COMMITTING inline.

  • ✅ Smallest diff; all infrastructure exists; follows proven job-submit pattern.
  • ❌ Blocking-condvar-per-write multiplies vtask pool throughput-starvation risk (no deadlock — ACKs arrive via iomgr; see §1 item 3); concurrent sync writes still share one condvar (broadcast wakeups checking per-LSN predicates works but is crude); apply-after-commit ordering not addressed (would be documented deviation).

Option B: New commit-barrier module

New commit.c owning Raft commitIndex logic: per-entry waiter registry (LSN → waiter list), matchIndex aggregation (acked_lsn scan on each ACK → advance committed_lsn → wake waiters at or below), explicit pending-entry queue for notification-shaped facts, hold/retry. Hooks call into it; repl.c feeds it ACKs.

  • ✅ Clean Raft semantics (commitIndex = median of matchIndex); supports many in-flight entries; single place to test invariants; natural home for own-term tracking and apply-after-commit sequencing.
  • ❌ Larger surface; new module must integrate with persist lock ordering and the fsync thread; more migration risk in one step.

Option C: Hybrid, phased (barrier-first ordering preserved)

  • Phase 1 (repl.c surgical): per-LSN wait predicate (acked_lsn >= entry_lsn count ≥ majority), remove n_active == 0 short-circuit for n_peers > 1, ACK-time quorum verification. Job submit immediately gets honest commits.
  • Phase 2 (persist.c + small commit registry): generalize the barrier to QUEUE_CTRL, WORKER_CTRL, and request-shaped job ops; introduce a minimal pending-entry registry only for notification-shaped facts (hold/retry until committed); config push error propagation.
  • Phase 3 (ha.c): own-term commit gate on promotion, then delete COMMITTING (+ watchdog, convergence checker, redirect fix).
  • Phase 4 (config_repl.c): ACK-driven catch-up continuation; quorum-transition logging.
  • ✅ Each phase independently testable and shippable; respects the brief's hard ordering (barrier before state removal); risk contained.
  • ❌ Per-entry waiter logic lands in two steps; requires discipline to not stop halfway.

4. Effort & Risk

Workstream Effort Risk Justification
Per-LSN ACK verification + TOCTOU closure S-M Low Fields exist; localized to repl.c wait/ack path
Barrier generalization (request-shaped ops) M Medium Proven pattern, but vtask pool throughput stall needs mitigation (commit-wait admission cap or async continuation — see Research Needed item 3)
Notification-shaped facts (pending-facts queue) M Medium Resolved: worker provides NO redelivery (verified), so a primary-side pending queue is the required mechanism; semantics now clear, implementation moderate
Provisional-state egress gating (replaces inversion) M Medium DECIDED: mutation order unchanged; tag entities with WAL LSN, filter dataex/scheduler export to committed-only (closes dispatch-of-uncommitted race); smaller than inversion but couples to §C reconciliation being real
Demotion/restart convergence (Req 2.5 gaps) M-L High NEW workstream: truncate-on-demotion, persist or reconcile committed_lsn at recovery, phantom in-memory state eviction (no teardown path exists; snapshot carries no entity state)
COMMITTING removal + own-term gate S-M Medium Concretely mapped (6 sites); must land after barrier
Config push error propagation S Low Two call sites
Leader-driven catch-up M Medium Continuation trigger redesign in streaming path

Overall: L–XL (2+ weeks across phases), Medium-High risk — dominated by the two High-risk research items.

5. Recommendations for Design Phase

Preferred approach: Option C (phased hybrid), with Phase 2's pending-entry registry designed Raft-shaped from the start (commitIndex from matchIndex aggregation) so it can absorb Phase 1's logic rather than duplicate it.

Research Needed (carry into design):

  1. Apply-after-commit strategy DECIDED (user, 2026-06-10): provisional-state model — in-memory state is a provisional view, persisted majority-committed log is authoritative. Mutation order unchanged; gate confirmation + irrevocable-action egress on commit. Remaining design details: entity commit-tagging mechanism (per-entity WAL LSN vs per-type watermark), which egress points are "irrevocable" (dataex/scheduler export: yes; status queries: no, documented deviation), and the egress filter's interaction with array jobs sharing one submit entry.
    • Mechanism direction (discussed 2026-06-10, STM evaluation — REFINED same day): full STM rejected — transaction duration mismatch (commit point is a majority network ACK; STM transactions can't span syscalls), C ecosystem dead (libitm unmaintained, TSX disabled), single-writer + per-object RTTI locks already cover the concurrency. An ARIES-style undo log was briefly considered, then dropped after the recovery-oriented reframing (user): this is a failover/recovery problem, not a transaction problem. Structural fact: a single-writer leader never truncates its own log while leader (Raft leaders only append), so a provisional entry on a live primary either commits later or the node is demoted — discard events coincide exactly with role/lifecycle events (demotion, restart, rejoin), never with normal operation. Therefore: no per-entry undo; discard is handled by recovery mechanisms — truncate divergent suffix + coarse memory rebuild via the existing persist_recover/replay_wal path at demotion/rejoin (rare, node not serving writes, cost acceptable). The only piece retained from the versioning discussion is the watermark egress filter (entity WAL-LSN tag, export only lsn <= committed_lsn to dataex/scheduler) — justified as failover correctness (prevent orphan dispatch of a job the post-failover cluster never heard of), not as transaction isolation.
    • xzarray shadow evaluated (2026-06-11): the dual-zarray COW (xzarray_freeze/xzarray_thaw, zarray.c:695-757 — built and unit-tested, zero production callers; kvmgr vfreeze NULL "wired in P0-2") is slot-level STRUCTURAL COW: slots hold pointers, so in-place field mutations (job state transitions) are visible through both views. Not suitable for the committed-view egress (reads are merged active-over-shadow, single generation would collide with the future checkpoint consumer, point-in-time vs advancing watermark) nor for provisional rollback (only covers inserts, not mutations; demotion-time eviction doesn't need lock-free semantics anyway). Its real home is the entity-bearing-snapshots backlog item (its original P0-2 purpose), which indirectly strengthens §C eviction option (iii). Watermark filter stands as the egress mechanism.
  2. Notification-shaped facts DECIDED (user, 2026-06-11): log-is-the-queue, Raft-pure — fact entries are appended to the WAL immediately and replicated/retried until committed with NO commit timeout (Raft leaders never time out entries; retry across quorum-loss periods per Req 2.8). No separate side-queue structure; the "pending facts" are simply the uncommitted log suffix under continuous retry. Crash-loss of uncommitted facts is accepted until worker-rejoin-resync (roadmap backstop) lands — strictly better than today's behavior. Remaining design details: idempotency guards in mark_job_* (needed for replay safety) and retry/backoff cadence.
  3. Wait mechanism under load: blocking condvar per vtask worker vs async continuation (queue reply closure, complete on commit). Measure vtask pool headroom; consider dedicated commit-wait sizing.
    • Verified inputs (2026-06-11): (a) vtask pool default is ALREADY 8 with --vcpus override (cluster.c:1082; the "default 4" in ha-async-io.md was stale — fixed). (b) No wakeup deadlock: WAL ACKs are processed on the iomgr thread (peer_ctrl_msg_handler → repl_ack_decode_and_dispatch → repl_on_ack, ha.c:2770-2792, 2748) — blocked vtask workers always get woken; all-workers-blocked costs throughput, not liveness. (c) Pool size should NOT scale with n_peers (user question, analyzed): blocked-thread count ≈ in-flight write ops × commit latency (Little's law); ACKs gather in parallel so peer count barely moves latency. Write concurrency and cores are the sizing inputs. (d) Recommended interim mitigation: commit-wait admission cap — at most n_vcpus - K concurrent blocking waits (reserve K lanes for non-blocking traffic), excess write ops fail fast with NOT_READY (CP-correct under overload). Async continuation remains the durable fix. (e) Sizing ownership (user, 2026-06-11): --vcpus is a tunable operator parameter — the right value is a deployment decision driven by host capability, cluster size, and workload; 8 is only the fallback default. Design constraint: all commit-wait correctness mechanisms must be expressed relative to n_vcpus (never absolute counts) so behavior is correct at any tuning; sizing guidance belongs in operator/tuning docs, not in spec-fixed constants.
  4. Own-term commit vehicle DECIDED (user, 2026-06-11): explicit no-op WAL entry at promotion — bounded post-election write-unavailability independent of traffic; an idle cluster's new primary becomes writable as soon as the no-op commits.
  5. Multi-entry in-flight semantics: confirm committed_lsn advancement is contiguous (no gaps) when multiple entries await ACKs.
  6. Req 2.5 verification RESOLVED into a workstream (deep-dive §C): five convergence gaps enumerated. Design must choose the phantom-eviction strategy: (i) full in-memory rebuild on demotion (needs new teardown path for jobmgr/queues/workers, then replay_wal from checkpoint after truncation), (ii) process self-restart on demotion (crude, safe, CP-consistent), or (iii) entity-bearing snapshots (depends on the deferred "Task 11" snapshot format). Also: persist committed_lsn (or reconcile it from the cluster at recovery) so restart does not replay the uncommitted suffix.

Constraints to respect: standalone n_peers <= 1 self-quorum unchanged; iomgr thread never blocks (waits live on vtask/delaymgr threads only); single-writer model; ha-wal-replication task 5.4 (live integration test) still pending — its 3-node test should be extended to cover the new barrier rather than duplicated.


Design Synthesis Outcomes (2026-06-11, design phase)

  • Generalization: Requirements 2.x (op barrier), 3.x (own-term gate), and the per-LSN quorum fixes (4.2–4.4) are one capability — a single commit engine (commit.c) with two completion modes: WAIT (request-shaped, blocking with timeout + admission cap) and RETAIN (fact-shaped, log-is-the-queue, no timeout). Config (5.x) stays a parallel simpler track (one in-flight change, own tag) sharing the majority arithmetic helper.
  • Build vs adopt: algorithm adopted from the Raft paper (§5.3 commit rule, §5.4.2 own-term no-op); machinery reuses existing assets — standby_conn_t.acked_lsn (matchIndex), repl_broadcast, catch-up streaming, persist_recover boot path (reused by demotion self-restart). No new libraries; no .capnp changes (WAL_NOOP is a controller-internal entry type).
  • Simplification (locked by user decisions): no undo log, no MVCC/committed-view machinery, no side pending-facts queue (the uncommitted log suffix IS the queue), no persisted commitIndex (volatile per Raft; rejoin reconciliation instead), egress filter = one watermark comparison on submit_lsn, demotion eviction = truncate + process self-restart via the existing boot path.
  • Design written to design.md; review gate passed (all 33 requirement IDs traced, no orphan components, boundary↔file alignment verified).

Deep-Dive Research: Requirements 2.2, 2.4, 2.5 (2026-06-10, second pass)

Three parallel code-explorer passes resolving the Unknown tags. All findings verified with file:line evidence.

§A — Req 2.2: Notification-shaped facts have NO redelivery anywhere (worker → controller)

Verdict: the controller must own durability of worker-reported job state facts. A pending-facts queue on the primary is the only option that doesn't expand scope into the worker protocol.

DECIDED (user, 2026-06-11): facts are retained and retried until committed — including across quorum-loss periods; the primary never discards an uncommitted fact while running and leading (Req 2.8). Worker-rejoin re-sync (roadmap backlog) is the designated backstop for facts that never reached the controller or were lost with it across failover.

  • Send path is enqueue-and-forget: worker job start/finish/fail → xcecli_notify_* (src/lib/client.c:289/373/392) → xce_notify (xceapi.c:270) → xce_request which enqueues and returns 0 immediately (request.c:309-331). No cbuf_get anywhere in the chain — the worker never reads a reply.
  • Errors are silently discarded twice: (1) the return value of xcecd_job_finished is ignored at its call sites (xcecd_worker_utils.c:297-337, with a literal /* FIXME: add error handling when msg send failure */); (2) if the controller's dispatch gate rejects the notify with -XCE_ERR_NOT_READY (cluster.c:252-258), the error reply reaches the worker but is dropped by cbuf_tryput returning -EAGAIN with no consumer (xcectx.c:82-88, 304).
  • No resync on rejoin: the worker rejoin path (checking_connection, controller_connector.c:176) re-issues only hello + join; xce_join_req carries only host resource attributes (request.c:1154-1181), no running-job state. Notifications lost during disconnect/failover are never re-delivered.
  • Controller side is also fire-and-forget: do_notify (cluster.c:118-157) sends no reply at all once the gate passes.
  • No idempotency guards: mark_job_start/finish/fail (job.c:583/638/696) unconditionally overwrite job->job.state — duplicate or out-of-order delivery (e.g., from a future retry/replay mechanism) is unguarded.
  • Scheduler-originated transitions differ: handle_jobdispatch (job.c:459) replies synchronously (status 0 at job.c:471) BEFORE processing allocations; dispatch results flow back to the scheduler via notify_dispatch (job.c:440). Retry is owned by the scheduler side (none found in scheduler.c at the reviewed path).

Pre-existing bug exposed: today, quorum-loss (or COMMITTING-window) rejection of OPCode_notify already loses job state transitions permanently — the worker never learns and never resends. The pending-facts queue fixes an existing silent-data-loss hole, not just a new-requirements need.

Design inputs: queue must persist facts arriving while quorum is absent and drain them (through the commit barrier) when quorum returns; needs bounded size + eviction policy; mark_job_* need state-machine guards so queue drain/replay is idempotent and ordered.

Companion backlog item (2026-06-10): worker-rejoin-resync added to .kiro/steering/roadmap.md — long-planned, covers the disconnect/failover window this queue cannot (facts the worker generated while not connected). Out of scope for this spec; the idempotency guards designed here are a shared prerequisite.

§B — Req 2.4: Apply-after-commit inversion is FEASIBLE (and fixes an existing race)

Verdict (superseded 2026-06-10, user decision): the PROVISIONAL-STATE MODEL is adopted instead of inversion. In-memory state is a provisional materialized view; the persisted majority-committed log is the authoritative state. Mutation order stays as-is; commit gating moves to (1) requester confirmation and (2) irrevocable-action egress points — the dataex/scheduler export must filter provisional entities (e.g., tag entities with their WAL LSN, export only lsn <= committed_lsn), closing the dispatch-of-uncommitted-job race noted below. This makes the §C reconciliation workstream mandatory core (provisional state must actually be discarded when the log disagrees). Requirements 2.4/2.7 amended accordingly. The inversion findings below are retained as evidence and fallback.

Original verdict: invert to log→commit→apply. All prerequisites verified; the rollback alternative is infeasible.

  • The BEFORE-hook seam exists and is free: xce_run_hooks aborts the chain when a HOOK_BEFORE_EVENT hook returns negative (hook.c:137) — veto semantics are built in. No registered hook currently does anything on BEFORE for job/queue/worker events (sched.c:244, apiserver.c:134, persist.c:1099 all AFTER-only), and callers currently ignore the BEFORE return code — both sides of the seam are unused and available.
  • WAL payloads are constructible pre-mutation: WAL_JOB_SUBMIT serializes the full xce_job_t, but every field is computable before table insertion — the job ID is assigned by the apiserver's idgen() before the RPC (idgen.c:22; submit->jobid pre-assigned, array jobs are base_id + i, job.c:34). State-transition payloads are just {jobid, new_state} where new_state is a per-transition constant (persist.c:1134-1169) — trivially known before mutation.
  • Replies don't need post-mutation state: handle_jobsubmit's reply carries only jobid (job.c:575-578), known pre-insertion.
  • Post-commit apply can reuse the replay functions: the standby/recovery apply path (apply_wal_entry → restore_job/restore_job_state, persist.c:691-769, job.c:71-121) is role-agnostic with an EEXIST idempotency guard (jobj.c:696-699). Caveat: apply_wal_entry skips hooks by design (persist.c:686) — primary post-commit apply must either call restore fns directly or suppress hook double-fire (sched/apiserver AFTER hooks still need to run exactly once).
  • Rollback (the alternative) is infeasible: there is no job deletion path — job_obj_killer is a stub (cluster.c:759-764), key_del is never called in src/controller/. Undo would be net-new, riskier code than inversion.
  • Inversion fixes an existing race: today the submitted job is visible in c->jobmgr to concurrent vtask readers (dataex/collect_jobs snapshots, up to 8 vtask threads, cluster.c:1082) for the entire majority-wait window — no per-job write lock is held during the AFTER hook (job.c:553). An uncommitted job can leak to the scheduler via a racing dataex pull. Apply-after-commit closes this.
  • One trade-off: state-transition callers hold the per-job WRITE lock across BEFORE/mutate/AFTER (job.c:629-631 etc.). Naively moving the commit wait inside that region blocks readers of that job for up to the commit timeout — design should release/restructure the lock around the wait (the pending-facts queue from §A naturally decouples this).

§C — Req 2.5: Convergence currently has FIVE gaps; safe_committed_lsn derivation is sound

Verdict: the "single outcome cluster-wide" requirement is NOT met today for the demoted-ex-primary and restart cases. New workstream required.

What works:

  • New leader truncates above safe_committed_lsn on promotion (ha.c:471); standby truncates on RDHUP using last_known_committed_lsn (ha.c:3416-3420); committed_lsn advances only on majority ACK (persist.c:1035).
  • safe_committed_lsn = majority-median of granted voters' committed LSNs, sorted descending, index n_granted/2 (ha.c:373-432) — conservative and safe (never includes an uncommitted entry; may under-estimate and over-truncate, which is correct-but-lossy for uncommitted suffixes).

The five gaps:

  1. No WAL truncation on demotion: raft_cb_become_follower (ha.c:627-690) cancels timers, closes vote endpoints, sets STANDBY — but never calls persist_truncate_wal_above. The ex-primary's divergent uncommitted suffix survives on disk until its own future RDHUP event. (Also: this production path never disables persistence hooks — ha_demote_to_standby (ha.c:1712-1724) does, but is not called here.)
  2. No in-memory phantom eviction on demotion: no path rebuilds or removes in-memory jobs/queues/workers applied from uncommitted entries. Phantoms persist indefinitely on the demoted node.
  3. Snapshots carry no entity state: the snapshot format is a 16-byte sentinel (magic + checkpoint_lsn, persist.c:435-437, "Task 11" placeholder); persist_load_snapshot (persist.c:680-683) only moves the LSN watermark. Snapshot transfer can therefore NEVER evict phantoms — option (iii) below depends on implementing the real format.
  4. Restart resurrects phantoms: committed_lsn is in-memory only (zero-init on boot, never persisted); replay_wal (persist.c:781-951) replays everything above checkpoint_lsn with no committed_lsn gate. A crashed ex-primary replays its own uncommitted suffix back into memory.
  5. No conflict detection in standby apply: apply_wal_entry advances current_lsn and applies unconditionally by type (persist.c:720-721) — entries from the new primary are applied on top of phantom state with no divergence check.

Eviction strategy options for design: (i) in-memory rebuild on demotion — new teardown for jobmgr/queues/workers, then truncate + replay_wal from checkpoint (most work, no restart); (ii) process self-restart on demotion after truncation — minimal code, CP-acceptable since the node is leaving leadership anyway; (iii) entity-bearing snapshot transfer from the new primary — depends on deferred Task 11 snapshot format. DECIDED (user, 2026-06-11): option (ii) — process self-restart on demotion (after truncating the divergent suffix). Simplest and battle-tested: recovery reuses the boot path; a demoted node's worker/scheduler connections migrate to the new primary anyway, so the restart's connection reset costs little. Rebuild-in-place (i) remains a future refinement only if restart proves operationally disruptive. For gap 4, note Raft treats commitIndex as VOLATILE (followers recover it from the leader): the Raft-conformant fix is leader-driven log reconciliation at rejoin (consistency check + truncate divergent suffix, then rebuild) rather than persisting committed_lsn locally — design should weigh reconciliation-at-rejoin vs local persistence; either must ensure the replayed-but-uncommitted suffix cannot leak through irrevocable egress before reconciliation completes.


Gap Re-Audit (2026-06-18)

Roll-up gap audit of the UMBRELLA contract against the landed sub-specs. The umbrella (ha-consistency-invariants) is design-generated and owns NO tasks of its own; it was decomposed (2026-06-11) into five implementing sub-specs. This pass maps every umbrella acceptance criterion to its owning sub-spec and verifies it against real code (file:line), then enumerates the invariants still OPEN with their owning future specs.

Method: codegraph + grep against src/controller/{commit.c,persist.c,ha.c,ha_convergence.c,config_repl.c,egress.c,job.c}; sub-spec tasks.md checkbox counts and spec.json; roadmap backlog (.kiro/steering/roadmap.md) for future-spec ownership.

Sub-spec landing status (verified against code, not just spec.json phase)

spec.json.phase is STALE for three sub-specs (still reads tasks-generated) and several tasks.md checkboxes were never re-ticked. The authoritative signal is the on-disk code + git history: the COMMITTING machinery is fully gone, the commit engine is live, demotion convergence is wired, and ha-commit-durability (23/23 tasks, all committed d9b43c2…1ca2d64) re-verified the whole protocol end-to-end. The audit below grades by CODE, flagging the stale-tracking discrepancy where it occurs.

Sub-spec tasks.md ticks Code verdict Notes
ha-commit-engine 12/18 ticked LANDED commit.c present & called from persist/ha/repl; per-LSN eval, self-quorum, own-term gate, admission cap all in code. Open boxes are stale tracking.
ha-commit-barrier-rollout 23/23 LANDED wal_classify totality (persist.c:2004) + idempotency guards (job.c:627-797) + reply-after-commit.
ha-single-leader-role 11/17 ticked LANDED Zero CTRL_PRIMARY_COMMITTING/committing_timeout_fn/ha_check_majority_convergence/repl_ack_count refs in src/controller (excl. testsuite). Open boxes are stale tracking.
ha-convergence-recovery 18/19 LANDED (1 E2E open) ha_convergence.c present + demotion path wired (ha.c:1188-1232); egress filter (egress.c). Only open: task 8 = live 3-node E2E test.
ha-commit-durability 23/23 LANDED Group-commit flusher + durable_lsn, commit≤durable invariant, durable-backed vote freshness, no-self-terminate fence.

Requirement → sub-spec → code roll-up map

Umbrella criterion Owning sub-spec Code evidence (file:line) Verdict
1.1, 1.2 direct PRIMARY, single role single-leader-role ha_promote_to_primary ha.c:2298; no COMMITTING enum anywhere MET
1.3 no redirect from primary single-leader-role ha_should_redirect ha.c:2187 (standby-only redirect) MET
1.4 status reports primary pre-own-term single-leader-role role set CTRL_PRIMARY directly at promotion (ha.c:2298-2315) MET
1.5 / 3.3 no wall-clock leadership change single-leader-role committing_timeout_fn deleted (zero refs); ha_check_priority_stepdown deleted (ha.c:1472) MET
2.1 confirm only after majority ACK commit-engine + barrier-rollout commit_submit WAIT commit.c:284; reply-after-commit per classification MET
2.2 requester ops fail on commit timeout commit-engine -ETIMEDOUT from commit_submit commit.c:340-359 MET
2.3 no commit-index advance without majority commit-engine commit_advance_locked (median of acked_lsn); per-LSN eval MET
2.4 provisional state, irrevocable egress gated convergence-recovery job_submit_committed/collect_jobs_committed egress.c:47-109 MET
2.5 single-outcome convergence incl. memory convergence-recovery demotion truncate-above-committed + self-restart ha.c:1188-1232, ha_convergence.c MET
2.6 monotonic commit index commit-engine monotonic max store + XCE_TESTING assert (commit.c) MET
2.7 reconstruct from committed log convergence-recovery + durability boot recovery replay; recovery_truncated verdict (durability task 3.1) MET
2.8 facts retained+retried until committed commit-engine + barrier-rollout RETAIN mode commit.c:296-302; log-is-the-queue; idempotency guards job.c:627-797 MET
3.1 no confirm before own-term entry commits single-leader-role + commit-engine ha_arm_own_term_gate ha.c:2259 + commit_set_writable_lsn commit.c:383 (writable_lsn gate) MET
3.2 pre-own-term: barrier or NOT_READY commit-engine own-term gate withholds WAIT completion; admission cap → NOT_READY MET
4.1 quorum counts only members able to receive commit-engine in_catch_up exclusion (ha.c:325; commit eval excludes catch-up) MET
4.2 commit requires per-entry ACKs commit-engine commit_on_ack reads registry acked_lsn, per-LSN median commit.c:366 MET
4.3 post-acceptance quorum shrink → fail commit-engine ACK-time re-eval + WAIT timeout; commit_on_membership_change commit.c:397 MET
4.4 never zero-peer majority (n_peers>1) commit-engine legacy n_active==0→success removed; n_peers≤1 short-circuit only (commit.c:330) MET
5.1–5.3 config change commit barrier barrier-rollout (config track) push_config_to_standbys returns -ETIMEDOUT; caller forwards (config_repl.c:941,1115-1120); committed_config_tag gated (config_repl.c:963-969) MET
6.1 replicate to all reachable standbys (upstream ha-wal-replication, verified) repl_broadcast full fan-out (unchanged) MET
6.2 catch-up without per-step heartbeat convergence-recovery ack-driven continuation ha_catchup_continuation_fn config_repl.c:1732 (off-iomgr delaymgr) MET
6.3, 6.4 snapshot fallback, re-include (upstream, verified) snapshot transfer + repl_complete_catchup (unchanged) MET
6.5 lag never blocks majority confirmation commit-engine majority-median threshold only MET
7.1 reject state changes without quorum commit-engine + ha gate dispatch gate + ACK-time enforcement (TOCTOU closed) MET
7.2 log quorum-loss enter/exit single-leader-role ha_log_quorum_transition (ha.c:1085, 2076, 3969, 5054) MET
7.3 serve status while degraded (gate allowlist, verified) dispatch-gate allowlist (status/replication) MET
7.4 auto-resume on quorum restore commit-engine + ha gate stateless gate re-eval; restore edge logged ha.c:5054 MET
7.5 standalone self-quorum commit-engine n_peers <= 1 self-quorum commit.c:100,330; ha.c:2037 MET

Umbrella criteria covered: 33/33 acceptance criteria MET in code. One sub-task remains unticked within scope — ha-convergence-recovery task 8 (live 3-node E2E convergence test). This is test-evidence, not a missing invariant: the convergence MECHANISM is implemented and unit/integration-tested; the standing 3-node E2E is the same pending live test inherited from ha-wal-replication task 5.4. Tag: MET (impl) / E2E-evidence pending.

Still-OPEN invariants (Deferred — owned by FUTURE specs, NOT umbrella scope)

These were declared out-of-scope by the umbrella (Boundary Context / Non-Goals) and remain unimplemented. They are Deferred, not regressions — the umbrella never promised them; it named the owning backstop.

Open invariant Why still open Owning future spec (roadmap) Effort Risk
Worker-rejoin job-state resync (facts lost across disconnect/failover, never reached controller) Primary-side RETAIN queue only covers facts the controller already received; worker notify is fire-and-forget with zero redelivery (bug-396). Designated backstop in umbrella Req 2.8 / boundary. worker-rejoin-resync (roadmap, planned, no effort yet) M Medium — touches worker join/hello protocol + reconcile-through-barrier; shares the already-landed mark_job_* idempotency guards
Automatic primary step-down on quorum loss Umbrella satisfies CP by BLOCKING writes (Req 7.1); step-down is an availability/clarity optimization, explicitly out of scope. primary step-down-on-quorum-loss (roadmap, optional CP follow-on) S–M Low — writes already blocked; step-down is additive role transition
Read consistency / leader leases Status queries may observe provisional/stale in-memory state (documented deviation); irrevocable-action egress already commit-gated (2.4). read-consistency / leader-leases (roadmap, Raft §8) M Medium — read-only lease protocol; must not regress write path
Entity-bearing snapshots ("Task 11" format) Snapshot is a 16-byte LSN sentinel; demotion eviction uses self-restart instead. Not required by any umbrella criterion; strengthens §C option (iii) and faster recovery. entity-bearing-snapshots (roadmap; dual-zarray COW already built/dormant) M Medium — serialization format + freeze/thaw wiring
Job-array single-WAL-record Each array element = one WAL/commit/push today; amplifies commit-engine load linearly. Correctness-neutral (each element still commits); performance only. job-array-single-wal-record (roadmap) M Medium — atomic array commit + scheduler local expansion

Roll-up conclusion

The umbrella contract is substantively SATISFIED: all 33 acceptance criteria are MET in landed code across the four core sub-specs (commit-engine, barrier-rollout, single-leader-role, convergence-recovery) plus the durability sub-spec, which closed bug-661 (ACK≠durable) and the no-self-terminate promotion fence. The COMMITTING transitional state is fully removed; majority ACK (now durable-majority) is the hard commit barrier for all state-changing ops; facts are retained-and-retried; egress is commit-gated; demotion converges via truncate+self-restart.

Status: PARTIAL only in the umbrella-as-program sense — the four explicitly-deferred invariants above remain owned by named future specs, and one in-scope live 3-node E2E test (convergence-recovery task 8) is still pending. No in-scope invariant is unimplemented.

Re-validation triggers still standing: worker-rejoin-resync landing → re-verify idempotency guards + fact replay ordering; entity-bearing snapshots landing → re-evaluate demotion self-restart vs snapshot rebuild; any new state-changing opcode/WAL type → must be classified (the wal_classify debug abort at persist.c:2046 enforces this).