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.
40 KiB
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)
- Apply-before-commit is universal — including job submit.
job_submit()(job.c:518) inserts the job into the in-memory table, THENxce_run_hooks(HOOK_AFTER_EVENT)(job.c:553) fires the WAL write + majority wait. For state transitions,mark_job_start/finish/...mutatejob->job.statebetween 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. - Majority-ACK verification is not per-entry.
repl_wait_majority_ack(repl.c:408-443) counts standbys withacked_lsn > 0— i.e., "ACKed anything, ever" — notacked_lsn >= this_entry_lsn. Once a standby ACKs once, it counts toward every subsequent quorum until disconnect. Concurrent sync appends share one globalack_condand would cross-satisfy each other. Req 4.2 needs per-LSN comparison; theacked_lsnfield already supports it. - Threading is permissive but finite. (Corrected 2026-06-11; original pass understated the pool and overstated the risk.) The sync wait is
pthread_cond_timedwaiton a vtask pool thread — default 8 threads,--vcpusCLI 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). - Job state transitions are reported facts, not requests.
on_job_start/finish/failarrive 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. - COMMITTING removal is concretely mapped.
raft_cb_become_leader(ha.c:505) sets COMMITTING; removal means: set CTRL_PRIMARY directly, move hook registration fromha_promote_to_primary(ha.c:1691-1698) into the become-leader path, delete the committing watchdog (ha.c:593-616, 3082-3125), makeha_check_majority_convergence(ha.c:1313) andrepl_ack_count/ha_repl_ack_receivedobsolete, adjust the COMMITTING guard in the FSM path (ha.c:2967) and the dispatch-gate COMMITTING branch (ha.c:1594-1612). - Catch-up is heartbeat-polled.
ha_stream_wal_to_standbysends 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 updatedapplied_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_lsncount ≥ majority), removen_active == 0short-circuit forn_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):
Apply-after-commit strategyDECIDED (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_walpath 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 onlylsn <= committed_lsnto 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.
- 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
Notification-shaped factsDECIDED (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 inmark_job_*(needed for replay safety) and retry/backoff cadence.- 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
--vcpusoverride (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 mostn_vcpus - Kconcurrent 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):--vcpusis 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 ton_vcpus(never absolute counts) so behavior is correct at any tuning; sizing guidance belongs in operator/tuning docs, not in spec-fixed constants.
- Verified inputs (2026-06-11): (a) vtask pool default is ALREADY 8 with
Own-term commit vehicleDECIDED (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.- Multi-entry in-flight semantics: confirm
committed_lsnadvancement is contiguous (no gaps) when multiple entries await ACKs. Req 2.5 verificationRESOLVED 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, thenreplay_walfrom 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: persistcommitted_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_recoverboot 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_requestwhich enqueues and returns 0 immediately (request.c:309-331). Nocbuf_getanywhere in the chain — the worker never reads a reply. - Errors are silently discarded twice: (1) the return value of
xcecd_job_finishedis 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 bycbuf_tryputreturning -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_reqcarries 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 overwritejob->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 vianotify_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_hooksaborts 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'sidgen()before the RPC (idgen.c:22;submit->jobidpre-assigned, array jobs arebase_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 onlyjobid(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_entryskips 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_killeris a stub (cluster.c:759-764),key_delis 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->jobmgrto concurrent vtask readers (dataex/collect_jobssnapshots, 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_lsnon promotion (ha.c:471); standby truncates on RDHUP usinglast_known_committed_lsn(ha.c:3416-3420);committed_lsnadvances only on majority ACK (persist.c:1035). safe_committed_lsn= majority-median of granted voters' committed LSNs, sorted descending, indexn_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:
- No WAL truncation on demotion:
raft_cb_become_follower(ha.c:627-690) cancels timers, closes vote endpoints, sets STANDBY — but never callspersist_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.) - 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.
- 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. - Restart resurrects phantoms:
committed_lsnis in-memory only (zero-init on boot, never persisted);replay_wal(persist.c:781-951) replays everything abovecheckpoint_lsnwith no committed_lsn gate. A crashed ex-primary replays its own uncommitted suffix back into memory. - No conflict detection in standby apply:
apply_wal_entryadvancescurrent_lsnand 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.phaseis STALE for three sub-specs (still readstasks-generated) and severaltasks.mdcheckboxes 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, andha-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).