44 KiB
Design: ha-convergence-recovery
Sub-spec 4 of 4 of the ha-consistency-invariants umbrella. This design slices the umbrella design's Demotion Convergence, Egress Filter, ACK-Driven Catch-up, and Quorum Observability components plus the role-lifecycle flow (
.kiro/specs/ha-consistency-invariants/design.md) — umbrella Migration Strategy phase 4. Discovery is complete via the umbrella'sresearch.md(deep-dive §C five convergence gaps; locked decisions) plus this spec's anchor-verification pass (research.md, 2026-06-11). Requirement IDs below are THIS spec's IDs (seerequirements.mdfor the umbrella mapping).
Overview
Purpose: This feature closes the five verified convergence gaps so the cluster converges to a single outcome for every log entry and the scheduler never acts on uncommitted state: a demoted ex-primary truncates its uncommitted WAL suffix and self-restarts through boot recovery; a rejoining node whose log runs ahead of the cluster's committed boundary reconciles by truncation and one restart; scheduler-bound job exports are gated on the commit watermark; catch-up streaming continues on WAL ACK instead of waiting for heartbeats; and quorum-loss/restore transitions are operator-visible.
Users: Cluster operators gain single-outcome failover semantics (no phantom state on demoted/rejoined nodes) and observable degraded periods; the scheduler is protected from orphan dispatch; lagging standbys catch up at ACK speed.
Impact: Extends raft_cb_become_follower (ha.c:2104-2247 — line drift only, was ha.c:627-690 at design time; currently neither truncates nor disables hooks, verified gap) with the demotion-convergence sequence; generalizes the RDHUP truncation (ha.c:3416-3420 at design time) to the rejoin path (Note: superseded by ha-term-safety — the rejoin/RDHUP watermark-truncate heuristic this spec built was retired and replaced by the raft library's (term,lsn)-driven on_truncate_from; see the Rejoin Reconciliation component below and that spec's design.md); adds submit_lsn to the job_t wrapper and a watermark predicate on the scheduler export seams; adds an ACK-driven continuation to catch-up streaming (ha_stream_wal_to_standby, now config_repl.c:2351 — today re-driven only by the next heartbeat reply); adds quorum-transition WARN logging. No commit semantics change: this spec consumes committed_lsn (ha-commit-engine) and never modifies commit.c.
Goals
- Demotion = disable hooks → truncate above
committed_lsn→ WARN-log range → process self-restart via existing boot recovery; exec failure is FATAL;XCE_TESTINGrestart seam (1.1–1.7) - Rejoin reconciliation: local tip beyond the primary's committed boundary → clamped truncation + one restart (2.1–2.3); single-outcome convergence E2E-verified (2.4)
- Commit-watermark egress filter on scheduler-bound exports only; status/API reads untouched (3.1–3.4)
- ACK-driven catch-up continuation with single-flight guard; heartbeat path stays fallback + snapshot initiator; parity triggers re-inclusion (5.1–5.3, 7.1)
- Quorum-loss/restore transitions WARN-logged once per change (8.1–8.3)
- Regression tests pinning existing full fan-out, snapshot fallback, re-inclusion, degraded-mode service (4.1, 6.1, 7.1, 9.1–9.2)
Non-Goals
- Commit-index advancement, majority evaluation, admission cap (ha-commit-engine — upstream, complete)
- WAL-type classification, reply ordering, config barrier (ha-commit-barrier-rollout)
- Promotion no-op, own-term gate consumers, CTRL_PRIMARY_COMMITTING removal (ha-single-leader-role; this spec's E2E depends on it having landed)
- Entity-bearing snapshots (roadmap — would upgrade eviction to in-place rebuild); read-consistency filtering of status queries; primary step-down on quorum loss; worker rejoin job-state resync (roadmap backstop)
- Standby-apply conflict detection beyond the rejoin truncation rule (umbrella gap 5 is subsumed by reconciliation: divergent suffixes are truncated at role/lifecycle events, never patched in place)
Boundary Commitments
This Spec Owns
- The demotion path of the role lifecycle: everything
raft_cb_become_followerdoes when leaving PRIMARY (hook disable, truncation, logging, self-restart) and the self-restart primitive (saved argv, exec, FATAL fallback, test seam) - Rejoin reconciliation at the standby's
CtrlHelloReplyintake (truncate-above-boundary decision, clamp, once-guard) (Note: superseded by ha-term-safety —ha_rejoin_reconcileis retained only as the intake hook that records the learned boundary; the truncate-above-boundary decision, clamp, and once-guard it describes were retired in favor of the raft library's term-aware truncation, see Rejoin Reconciliation component below) job_t.submit_lsntagging (live append + replay) and the commit-watermark predicate on scheduler-bound export seams- The ACK-driven catch-up continuation trigger (walAck branch → scheduled streaming) including the single-flight guard and the parity call to
repl_complete_catchup - Quorum-loss/restore transition logging and its
quorum_loststate flag - Regression tests for umbrella 6.1/6.3/6.4/7.3 and the 3-node convergence E2E (extends ha-wal-replication task 5.4)
Out of Boundary
commit.c/include/commit.h— read-only consumer ofcommitted_lsn; never modified hererepl_wait_majority_ack, write-path classification, reply ordering (ha-commit-barrier-rollout); promotion path of the lifecycle (raft_cb_become_leader) and COMMITTING removal (ha-single-leader-role)ha_stream_wal_to_standbyinternals and the WAL batch wire format (ha-wal-replication — the continuation calls it, unchanged)persist_truncate_wal_above,persist_recover,replay_walinternals — reused as-is- Status/API read paths (apiserver.c:83, opget.c:173) — intentionally unfiltered (documented deviation)
- Worker/scheduler reconnection behavior after the restart this spec introduces (worker-join-refactor / worker-rejoin-resync)
Allowed Dependencies
persist_t.committed_lsnatomic (lock-free reads) — the watermark and truncation boundary; ha-commit-engine owns advancementpersist_truncate_wal_above(persist.c:1306),persist_recoverboot path — truncation + rebuild primitivesha_stream_wal_to_standby(config_repl.c:1450) andrepl_start_catchup/repl_complete_catchup(repl.c:507/521) — existing catch-up machinery;repl_complete_catchupalready feedscommit_on_membership_changevia ha-commit-engine wiring- iomgr/vtaskmgr/delaymgr threading rules per
ha-async-io.md: the walAck branch andraft_cb_become_followermay run on iomgr — file I/O (truncation, WAL streaming) and exec are always deferred to delaymgr/vtask tasks xce_disable_hook/ hook registration guard (hooks_registered) — existing hook lifecycle- Established
XCE_TESTINGfunction-pointer seam pattern (e.g.,ha_repl_stream_wal_hook,xce_wal_stream_request_hook)
Revalidation Triggers
- ha-commit-engine changing
committed_lsnownership or making it persisted → re-verify rejoin reconciliation and the egress watermark - ha-commit-barrier-rollout landing (fact allowlist) → re-verify the degraded-mode gate pin: the no-quorum rejected set becomes request-shaped opcodes only; fact-shaped opcodes (
OPCode_notify/update/setpreason/clrpreason) are admitted - ha-single-leader-role landing → re-anchor the quorum-transition log call site at promotion standby registration (ha.c:531), which lives inside
raft_cb_become_leader(ha.c:445-617) — code that spec owns and rewrites wholesale. (Resolved as-built: re-anchored to ha.c:2042 insideraft_cb_become_leader, now ha.c:1615-2103; the demotion insertion point re-verified at ha.c:2104-2247. Both current per the fact-check pass below.) - ha-single-leader-role changing the become-follower/become-leader callback contract → re-verify the demotion sequence insertion point
- Entity-bearing snapshots landing → re-evaluate restart-based eviction vs snapshot-based rebuild (umbrella revalidation trigger)
- Any new scheduler-bound export path → must adopt the watermark predicate (same rule as new WAL types must be classified)
- worker-rejoin-resync landing → re-verify restart interaction (facts lost across the restart are its recovery target)
Architecture
Existing Architecture Analysis
Note: this subsection is a research-time (2026-06-11) snapshot of the PRE-implementation baseline — the "verified gap" facts are what the code lacked before this spec ran. Where a later spec has since changed the mechanism, a note is added; otherwise only stale
file:linecitations are corrected in place.
raft_cb_become_follower(now ha.c:2104-2247, was ha.c:627-690 at design time) cancels timers, closes vote endpoints, sets CTRL_STANDBY — verified: no truncation, no hook disable (the disable logic exists only in the uncalledha_demote_to_standby, now ha.c:5081-5094, was ha.c:1712-1726 at design time)- The RDHUP failover path already truncates above the locally known committed boundary (ha.c:3416-3420 at design time) — the pattern this spec generalizes to demotion and rejoin. (Note: superseded by ha-term-safety — this ad hoc watermark-truncate pattern, and the rejoin generalization built on it, was itself replaced by the raft library's (term,lsn)-driven
on_truncate_from/ha_cb_truncate_from_stub, ha.c:2638-2655, whose own doc comment states it "REPLACES the committed-boundary truncate heuristic on the library-driven path"; see that spec's design.md) - The standby learns the primary's
committed_lsnat theCtrlHelloReplyintake (now cluster.c:411-419, was cluster.c:283-298 at design time, stored toha->last_known_committed_lsn) — the rejoin-reconciliation evaluation point - Catch-up is heartbeat-polled: the stateReport branch (now ha.c:6683, was ha.c:2734) drives
ha_dispatch_repl_action_for_standby(now ha.c:4331-4466, was ha.c:1373-1478), one action per heartbeat cycle; the walAck branch (now ha.c:6764-6812, was ha.c:2737-2754) only records the ACK. Replication target is already derived asmax(committed_lsn, last_written)(now viaha_compute_repl_target_lsn, called at ha.c:6759, was inline at ha.c:2727-2732) repl_complete_catchup(now repl.c:769, was repl.c:521 at design time) has no production caller (discovered gap — in_catch_up is set by broadcast-overflow eviction, repl.c:240, and never cleared); the ACK-driven continuation supplies the missing parity trigger- Scheduler-bound egress has two seams: collect-based full push (
xcesched_push_jobsnow sched.c:214, was sched.c:169, on scheduler join) and the incremental JOB_NEW push (sched_job_new_funnow sched.c:286, was sched.c:236-248 →xcesched_push_joblist); both must apply the watermark. Read paths (apiserver.c:83, opget.c:173 — both confirmed current, unchanged) sharecollect_jobs(dataex.c:294 — confirmed current, unchanged) and stay unfiltered - No saved argv / exec machinery exists anywhere in the controller — self-restart is net-new (main.c:112 at design time; landed at main.c:291-301 as-built)
Architecture Pattern & Boundary Map
graph TB
subgraph role_lifecycle[role lifecycle - ha.c]
BF[raft_cb_become_follower<br/>from PRIMARY]
DC[demotion convergence task<br/>delaymgr]
SR[ha_self_restart<br/>exec or test seam]
end
subgraph rejoin[rejoin - cluster.c + ha.c]
CHR[CtrlHelloReply intake]
RR[rejoin reconciliation]
end
subgraph persist[persist.c - reused]
TR[persist_truncate_wal_above]
REC[persist_recover boot replay]
end
subgraph egress[egress - dataex.c, sched.c, job.c]
TAG[submit_lsn tagging]
PRED[job_submit_committed predicate]
PUSH[scheduler push paths]
end
subgraph catchup[catch-up - ha.c + config_repl.c]
ACK[walAck branch iomgr]
CONT[continuation task delaymgr]
STREAM[ha_stream_wal_to_standby]
RCC[repl_complete_catchup]
end
QO[quorum observability - ha.c]
CL[(committed_lsn<br/>ha-commit-engine owned)]
BF --> DC --> TR
DC --> SR
CHR --> RR --> TR
RR --> SR
SR -. process restart .-> REC
TAG --> PRED
CL --> PRED --> PUSH
ACK -->|behind target| CONT --> STREAM
ACK -->|at parity| RCC
CL --> ACK
QO -. WARN once per transition .- CL
Architecture Integration (sliced from umbrella):
- Selected pattern: convergence by recovery, not by transaction — discard events coincide with role/lifecycle events (demotion, rejoin), handled by truncate + boot-recovery rebuild; the only steady-state mechanism is the one-comparison watermark filter (umbrella-locked)
- Existing patterns preserved: RDHUP truncation pattern, heartbeat decision loop (kept as fallback), enqueue-only streaming,
XCE_TESTINGseams,errnoxearly-return style - New code rationale: each gap closes with the smallest insertion at a verified seam; no new module — all changes live in the files that own the respective seams
- Steering compliance: no file I/O or blocking on iomgr (convergence + continuation run on delaymgr tasks); TDD with testsuite/ tests first
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Backend / Services | C (gnu17), pthreads, stdatomic | demotion/rejoin/egress/catch-up logic | no new dependencies, no .capnp changes |
| Data / Storage | existing WAL + snapshot sentinel (persist.c) | truncation boundary + boot rebuild | submit_lsn is in-memory only (recomputed from entry LSN at replay) |
| Process | execv re-exec of saved argv | demotion/rejoin eviction | FATAL on failure; seam under XCE_TESTING |
| Build / Test | meson, testsuite/ pattern | new test files in controller_unit_test_src |
E2E via /xce-deploy-ha |
File Structure Plan
Directory Structure
src/controller/
├── ha.c # MODIFIED — demotion branch inline in raft_cb_become_follower, walAck
│ # continuation trigger, quorum transition call sites
├── ha_convergence.c # NEW (as-built, not in original plan) — ha_self_restart,
│ # ha_rejoin_reconcile, cluster_apply_ctrlhello_reply,
│ # ha_log_quorum_transition, ha_demotion_convergence_do_run
│ # all landed here instead of ha.c
├── ha.h # MODIFIED — quorum_lost flag, catchup_inflight tracking, restart-hook
│ # seam declaration, new function prototypes
├── main.c # MODIFIED — save argv for re-exec (controller_saved_argv accessor)
├── cluster.c # MODIFIED — CtrlHelloReply intake calls rejoin reconciliation
├── config_repl.c # MODIFIED — catch-up continuation task fn (wraps ha_stream_wal_to_standby)
├── egress.c # NEW (as-built, not in original plan) — job_submit_committed predicate +
│ # collect_jobs_committed, factored out of dataex.c for
│ # test-binary linkage (research D3 refinement; declared in
│ # include/cluster.h, not dataex.h)
├── sched.c # MODIFIED — scheduler push paths consume the predicate (full + JOB_NEW push)
├── job.c # MODIFIED — restore_job tags submit_lsn from the replayed entry
├── persist.c # MODIFIED — persist_job_hook_fun tags submit_lsn at live append;
│ # apply_wal_entry passes entry LSN to restore_job
└── testsuite/
├── test_demotion_convergence.c # NEW — demotion + rejoin reconciliation + restart seam
├── test_egress_filter.c # NEW — watermark filter on scheduler export seams
└── test_convergence_verification.c # NEW — ACK-driven catch-up, quorum logging, and
# regression pins for 6.1/6.3/6.4/7.3 (umbrella)
src/include/xce/types.h # MODIFIED — job_t wrapper gains uint64_t submit_lsn
src/controller/meson.build # MODIFIED — register new test files
Modified Files (what and why)
ha.c/ha.h— demotion branch inraft_cb_become_follower(ha.c:2104-2247; detect prior role PRIMARY → disable hooks inline → schedule convergence task); walAck-branch (ha.c:6764-6812) continuation scheduling with per-standby single-flight guard and parityrepl_complete_catchupcall;ha_log_quorum_transition()called from the standby_endpoints mutation sites (now ha.c:2042, 7651, 9350) and the gate rejection backstop (ha.c:4617). As-built,ha_self_restart()andha_rejoin_reconcile()landed in a newha_convergence.c(see File Structure Plan) rather thanha.cdirectly.main.c— store argc/argv before parsing (main.c:291-301); exposecontroller_saved_argv()for re-execcluster.c—CtrlHelloReplyintake (now cluster.c:411-419) additionally invokesha_rejoin_reconcile(c, chrep->committed_lsn)viacluster_apply_ctrlhello_reply. (Note: superseded by ha-term-safety —ha_rejoin_reconcilenow only records the boundary; see Rejoin Reconciliation component.)config_repl.c—ha_catchup_continuation_fn(now config_repl.c:2839, delaymgr task body): re-derive target, callha_stream_wal_to_standby(config_repl.c:2351), clear the in-flight flagegress.c(as-built; design originally assigned this todataex.c, see File Structure Plan) —job_submit_committed(cluster_t*, job_t*)predicate;collect_jobs_committed()wrapper used only by scheduler-bound callers (read paths keep plaincollect_jobs, dataex.c:294)sched.c—xcesched_push_jobs(sched.c:214) collects via the committed-only wrapper;xcesched_push_joblist(sched.c:70, JOB_NEW incremental push) filters its list with the same predicatejob.c/persist.c— tagging: live path setssubmit_lsn = written_lsninpersist_job_hook_fun(now persist.c:6509); replay path threadshdr->lsnfromapply_wal_entry(now persist.c:3281) intorestore_job(now job.c:274)src/include/xce/types.h—job_twrapper (types.h:483-490) gainsuint64_t submit_lsn(0 = unknown/pre-feature = exportable); wire typexce_job_tuntouched
System Flows
Demotion convergence (role lifecycle, after this spec — umbrella flow sliced)
sequenceDiagram
participant RL as raft library
participant BF as raft_cb_become_follower
participant DM as delaymgr task
participant P as persist.c
participant OS as process
RL->>BF: higher term seen while PRIMARY
BF->>BF: disable persistence hooks (inline, first)
BF->>BF: set CTRL_STANDBY, cancel timers (existing)
BF->>DM: schedule demotion convergence (one-shot)
DM->>P: persist_truncate_wal_above(committed_lsn)
DM->>DM: WARN log: truncated (committed_lsn, prior tip]
DM->>OS: ha_self_restart: execv(saved argv)
alt exec fails
OS->>OS: FATAL (abort) — never continue divergent
else XCE_TESTING
DM->>DM: ha_restart_hook seam invoked instead
end
OS-->>P: boot: persist_recover replays committed prefix only
Key decisions: hooks are disabled synchronously in the callback (no provisional entry may be produced after the higher term is observed — 1.1); truncation and exec are deferred off the callback thread (iomgr-safe — D1); truncation always precedes restart so boot replay yields committed-only state (1.2, 1.3, 1.7); crash before/during the task is safe — the rejoin reconciliation flow covers the untruncated-suffix case (Note: as of ha-term-safety, that coverage is via the raft library's term-aware truncation, not this spec's rejoin-reconcile truncate path — see Rejoin Reconciliation component). The "exec fails → FATAL (abort)" branch in the diagram above is also superseded: see the Self-Restart Primitive component and Error Handling section for the current fence-and-retry behavior.
Rejoin reconciliation (generalizes RDHUP truncation, ha.c:3416-3420 at design time)
(Note: superseded by ha-term-safety, closed 2026-07-17.) The flow and "Key decisions" below describe this spec's original clamped-truncate-and-restart mechanism, which is no longer live.
ha_rejoin_reconcile(ha_convergence.c:467-493) is now a documented no-op: the truncate-above-boundary decision, clamp, and once-guard were retired in favor of the raft library's own (term,lsn) log-matching on incoming AppendEntries, which driveson_truncate_fromto delete exactly the divergent suffix without a self-restart. The function is retained only as theCtrlHelloReplyhook that records the learned committed boundary. See ha-term-safety's design.md for the current mechanism.
sequenceDiagram
participant PRI as primary
participant CL as cluster.c intake
participant RR as ha_rejoin_reconcile
participant P as persist.c
PRI->>CL: CtrlHelloReply (committed_lsn)
CL->>CL: store last_known_committed_lsn (existing)
CL->>RR: evaluate(local tip vs boundary)
alt tip > boundary and not already reconciled
RR->>P: truncate above max(boundary, local committed_lsn)
RR->>RR: WARN log range, set once-guard
RR->>RR: ha_self_restart (same primitive)
else tip <= boundary
RR->>CL: no action — join proceeds (2.3)
end
Key decisions: the clamp max(primary_reported, local committed_lsn) never discards the locally known committed prefix (2.2); "once" is structural — after restart the tip equals the boundary, so the condition cannot re-fire (plus a defensive in-process flag) (2.1); together with demotion convergence and the never-retroactive-success rule this yields single-outcome convergence, asserted end-to-end (2.4).
Commit-gated scheduler egress
Live submit: persist_job_hook_fun appends the submit entry and tags j->submit_lsn = written_lsn. Export time (scheduler-bound only — full push on scheduler join, JOB_NEW incremental push): job_submit_committed admits the job iff submit_lsn == 0 || submit_lsn <= committed_lsn (one atomic load, lock-free — 3.1, 3.2). Replay/standby apply tags from the entry LSN; on a promoted primary, replayed entries sit at or below the promotion truncation point, so restored jobs are exportable by construction (3.3). Status/API readers bypass the predicate entirely (3.4). Accepted edge (documented): a timeout-failed submit that later commits reaches the scheduler at the next scheduler (re)join full push — the requester keeps its failure answer (2.4 alignment).
ACK-driven catch-up continuation
sequenceDiagram
participant S as standby
participant IO as iomgr (walAck branch)
participant DM as delaymgr continuation
participant CR as ha_stream_wal_to_standby
S->>IO: WalAckReply(acked_lsn)
IO->>IO: repl_on_ack (existing; feeds commit engine)
alt acked_lsn < target = max(committed, last_written)
IO->>DM: schedule continuation if not already in flight
DM->>CR: stream [acked_lsn+1, target] (enqueue-only)
DM->>DM: clear in-flight flag
CR-->>S: next batches
else acked_lsn >= target
IO->>IO: repl_complete_catchup → re-inclusion (7.1)
end
Key decisions: continuation is scheduled from the walAck branch (ha.c:2737-2754) but executes on delaymgr (streaming reads WAL files — never on iomgr); the per-standby single-flight flag bounds concurrency (5.2); target re-derivation each round naturally extends the stream to entries appended meanwhile (5.1); the heartbeat decision loop is untouched — it remains the fallback initiator and the only snapshot-decision owner (5.3, 6.1); parity wires the existing-but-untriggered repl_complete_catchup (7.1).
Requirements Traceability
| Requirement | Summary | Components | Flows |
|---|---|---|---|
| 1.1 | hooks disabled on demotion before anything else | Demotion Convergence | demotion |
| 1.2 | truncate above committed_lsn on demotion | Demotion Convergence | demotion |
| 1.3 | self-restart rebuilds via boot recovery | Demotion Convergence, Self-Restart Primitive | demotion |
| 1.4 | exec failure → FATAL | Self-Restart Primitive | demotion |
| 1.5 | WARN log with truncation range | Demotion Convergence | demotion |
| 1.6 | XCE_TESTING restart seam | Self-Restart Primitive | demotion |
| 1.7 | state reconstructible from committed log | Demotion Convergence (boot recovery reuse) | demotion |
| 2.1 | rejoin: tip > boundary → truncate + restart once | Rejoin Reconciliation | rejoin |
| 2.2 | never truncate own committed prefix | Rejoin Reconciliation (clamp) | rejoin |
| 2.3 | tip <= boundary → no action | Rejoin Reconciliation | rejoin |
| 2.4 | single outcome, never retroactive success | Demotion Convergence + Rejoin Reconciliation (E2E) | demotion, rejoin |
| 3.1 | uncommitted submit excluded from scheduler export | Egress Filter | egress |
| 3.2 | included once committed | Egress Filter | egress |
| 3.3 | submit_lsn tagging live + replay | Egress Filter (tagging) | egress |
| 3.4 | status/API reads unfiltered | Egress Filter (boundary) | egress |
| 4.1 | full fan-out to all reachable standbys | Convergence Verification Suite (existing repl_broadcast) |
— |
| 5.1 | continuation on walAck, no heartbeat wait | ACK-Driven Catch-up | catch-up |
| 5.2 | single-flight per standby | ACK-Driven Catch-up | catch-up |
| 5.3 | heartbeat loop stays fallback + snapshot initiator | ACK-Driven Catch-up (boundary) | catch-up |
| 6.1 | snapshot fallback for far-behind standby | Convergence Verification Suite (existing path) | — |
| 7.1 | re-inclusion at parity | ACK-Driven Catch-up (parity trigger), Convergence Verification Suite | catch-up |
| 8.1 | quorum-loss logged with counts | Quorum Observability | — |
| 8.2 | quorum-restore logged | Quorum Observability | — |
| 8.3 | once per transition, no storms | Quorum Observability | — |
| 9.1 | status served while quorum lost | Convergence Verification Suite (existing gate allowlist) | — |
| 9.2 | replication traffic served while quorum lost | Convergence Verification Suite (existing gate allowlist) | — |
Components and Interfaces
| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| Demotion Convergence | controller/ha.c | PRIMARY→follower: disable hooks, truncate, log, restart | 1.1–1.3, 1.5, 1.7, 2.4 | persist truncation + recovery (P0), delaymgr (P0), Self-Restart (P0) | State |
| Self-Restart Primitive | controller/ha.c, main.c | re-exec saved argv; FATAL on failure; test seam | 1.3, 1.4, 1.6 | saved argv (P0) | Service |
| Rejoin Reconciliation | controller/ha.c, cluster.c | clamped truncation + one restart at rejoin | 2.1–2.4 | CtrlHelloReply intake (P0), persist truncation (P0), Self-Restart (P0) | State |
| Egress Filter | controller/dataex.c, sched.c, job.c, persist.c, types.h | watermark gate on scheduler-bound exports | 3.1–3.4 | committed_lsn (P0, read-only) | Service |
| ACK-Driven Catch-up | controller/ha.c, config_repl.c | continuation on walAck; parity re-inclusion | 5.1–5.3, 7.1 | walAck branch (P0), ha_stream_wal_to_standby (P0), repl_complete_catchup (P0), delaymgr (P0) | Event |
| Quorum Observability | controller/ha.c, ha.h | WARN once per quorum transition | 8.1–8.3 | ha_has_write_quorum (P0) | Service |
| Convergence Verification Suite | testsuite/, E2E | pin existing 6.1/6.3/6.4/7.3 behaviors + 3-node convergence | 2.4, 4.1, 6.1, 7.1, 9.1, 9.2 | /xce-deploy-ha (E2E only; needs ha-single-leader-role) | — |
Demotion Convergence + Self-Restart Primitive (ha.c, main.c)
| Field | Detail |
|---|---|
| Intent | Discard the demoted ex-primary's provisional state by truncation + boot-recovery restart |
| Requirements | 1.1–1.7, 2.4 |
Responsibilities & Constraints
raft_cb_become_followergains a was-PRIMARY branch (prior role read before the existing role store): synchronously disable the three persistence hooks (reuse theha_demote_to_standbydisable sequence +hooks_registeredreset, ha.c:1712-1726 — currently uncalled from this path), then schedule the one-shot convergence task; all existing behavior (timer cancel, vote-ep close, STANDBY store, watchdog arm) is preserved for the non-PRIMARY cases- Convergence task (delaymgr):
persist_truncate_wal_above(&c->persist, committed_lsn)wherecommitted_lsnis read frompersist_tat task time; WARN with both LSNs (boundary and prior tip); thenha_self_restart(c) - Deliberate scoping: a demotion from
CTRL_PRIMARY_COMMITTING(a state still extant if this spec lands before ha-single-leader-role removes it) takes the legacy become-follower path with no truncation/restart — acceptable because a COMMITTING node has no registered persistence hooks and has appended no new entries, so its exposure is identical to today's ha_self_restart: flush logs;execvofcontroller_saved_argv(); if exec returns → ERROR +abort()(FATAL — 1.4). UnderXCE_TESTING, invokeha_restart_hook(c)instead and return (1.6); a settable fatal seam lets tests assert the FATAL path without dying- fd hygiene: before exec, verify/ensure listener and peer fds do not leak into the new image (CLOEXEC audit; boot path re-creates all endpoints)
- Crash safety: if the node dies before the task runs, its untruncated suffix is handled by Rejoin Reconciliation at the next join — the two components together guarantee 2.4
Service Interface
/* ha.h — controller-internal */
/* Saved-argv accessor (main.c populates before argp_parse). NULL-terminated. */
char **controller_saved_argv(void);
/* Truncate-and-restart primitive. Never returns in production (exec or abort).
* Under XCE_TESTING returns 0 after invoking ha_restart_hook. */
int ha_self_restart(cluster_t *c);
#ifdef XCE_TESTING
extern int (*ha_restart_hook)(cluster_t *c); /* invoked instead of execv */
extern void (*ha_fatal_hook)(const char *msg); /* invoked instead of abort */
#endif
- Preconditions: persistence hooks already disabled; truncation completed before
ha_self_restartis called - Postconditions (production): process image replaced; next boot replays only the surviving prefix
- Invariants: no new WAL entry is appended by this node between hook-disable and exec
Rejoin Reconciliation (ha.c + cluster.c intake)
| Field | Detail |
|---|---|
| Intent | Converge a rejoining node whose log runs ahead of the cluster's committed boundary |
| Requirements | 2.1–2.4 |
(Note: superseded by ha-term-safety, closed 2026-07-17 — the mechanism below describes this spec's original design/as-shipped behavior; see the current-state summary immediately after.)
Original design/as-shipped: new int ha_rejoin_reconcile(cluster_t *c, uint64_t primary_committed_lsn) called from the CtrlHelloReply intake (cluster.c:283-298 at design time, now cluster.c:411-419) after the existing last_known_committed_lsn store. Decision: local_tip = persist.current_lsn - 1; if local_tip > primary_committed_lsn and the once-guard is clear → cutoff = max(primary_committed_lsn, local committed_lsn) (clamp, 2.2) → persist_truncate_wal_above(cutoff) → WARN with range → set once-guard → ha_self_restart. Otherwise return 0 with no side effects (2.3). The truncation/restart work is deferred to a delaymgr task if the intake runs on iomgr (same rule as demotion). Generalizes the RDHUP truncation (ha.c:3416-3420 at design time), which the design assumed remains in place for the failover path. Once-guard is structural (post-restart tip equals the boundary) plus a defensive _Atomic int rejoin_reconciled for the process lifetime (2.1).
Current state (ha_convergence.c:467-493): ha-term-safety task 3.2 retired this entire clamped-truncate-and-restart body. ha_rejoin_reconcile now ignores primary_committed_lsn and unconditionally returns 0 — divergent-suffix reconciliation is owned by the raft library instead: an incoming AppendEntries is routed through raft_on_append_entries_request, whose (term,lsn) log-matching drives on_truncate_from (ha_cb_truncate_from_stub, ha.c:2638-2655) to delete exactly the divergent suffix, never a committed-prefix entry, and never by an LSN watermark. A node whose tip runs ahead keeps its entries until the leader's AE proves the conflict by term; until reconciled the existing promotion-time fence keeps it non-promotable without self-restarting. The function is retained solely as the CtrlHelloReply hook (now wired via cluster_apply_ctrlhello_reply, ha_convergence.c:512-524) that still records the learned committed boundary. The Testing Strategy section's rejoin-reconcile test descriptions below are similarly stale; current tests (test_rejoin_no_watermark_truncate_no_restart, test_rejoin_repeated_calls_remain_no_op) pin the no-op contract instead.
Egress Filter (dataex.c, sched.c, job.c, persist.c, types.h)
| Field | Detail |
|---|---|
| Intent | One-watermark commit gate on scheduler-bound job exports |
| Requirements | 3.1–3.4 |
Responsibilities & Constraints
job_twrapper (types.h:483-485) gainsuint64_t submit_lsn;0= unknown/pre-feature = exportable (legacy WALs and pre-tagging jobs never strand); wire typexce_job_tunchanged- Tagging: live —
persist_job_hook_fun(persist.c:1107-1125) setssubmit_lsn = written_lsnon each submitted job after a successful append; replay/standby apply —apply_wal_entry(persist.c:729-731) passeshdr->lsntorestore_job(job.c:71), which tags the restored job (3.3) - Predicate (dataex.c):
bool job_submit_committed(cluster_t *c, job_t *j)→j->submit_lsn == 0 || j->submit_lsn <= atomic_load(committed_lsn); lock-free, one comparison - Consumers (scheduler-bound only):
collect_jobs_committed()wrapper in dataex.c used byxcesched_push_jobs(sched.c:183); the JOB_NEW incremental push (xcesched_push_joblist, fed bysched_job_new_funsched.c:236-248) filters its list with the same predicate. Both seams verified in the anchor pass — research D3 - Non-consumers (3.4): apiserver.c:83 and opget.c:173 keep plain
collect_jobs— status/API reads intentionally unfiltered (documented deviation; read consistency out of scope)
Service Interface
/* dataex.h additions — controller-internal */
bool job_submit_committed(cluster_t *c, job_t *j); /* watermark predicate */
void collect_jobs_committed(cluster_t *c, collect_check_fn_t fn, xlist_t *l);
/* identical contract to collect_jobs, additionally excluding jobs whose
* submit entry is uncommitted; for scheduler-bound callers ONLY */
- Invariant: a job with
submit_lsn > committed_lsnis never present in a scheduler-bound export payload; the same job remains visible to status/API reads
ACK-Driven Catch-up (ha.c walAck branch + config_repl.c continuation)
| Field | Detail |
|---|---|
| Intent | Continue catch-up streaming at ACK speed; re-include at parity |
| Requirements | 5.1–5.3, 7.1 |
Responsibilities & Constraints
- Trigger (iomgr, ha.c:2737-2754): after the existing
repl_on_ack, compareacked_lsntotarget = max(committed_lsn, last_written)(same derivation as ha.c:2727-2732). Behind target → schedule the continuation task unless the standby'scatchup_inflightflag is set (5.2); at/above target →repl_complete_catchup(&c->persist.repl, ep)(7.1 — wires the existing, currently untriggered re-inclusion; ha-commit-engine's wiring propagatescommit_on_membership_change) - Continuation task (delaymgr, config_repl.c): re-derive target, call
ha_stream_wal_to_standby(ep, wal_path, acked_lsn, target)(unchanged internals, enqueue-only), clearcatchup_inflight. Endpoint validity is re-checked against the standby registry at task time (the ep may have RDHUPed between schedule and run) - Per-standby state:
_Atomic int catchup_inflightkeyed by the standby slot (placed alongside the existing per-peer arrays inha_state_torstandby_conn_t— slot identity must survive between schedule and completion) - The heartbeat decision loop (
ha_dispatch_repl_action_for_standby, ha.c:1373-1478) is byte-identical: fallback initiator for standbys that never ACK, and sole owner of the snapshot decision (5.3; snapshot fallback 6.1 of this spec verified, not modified) - Never blocks iomgr: scheduling is O(1); all file I/O is inside the delaymgr task
Quorum Observability (ha.c, ha.h)
| Field | Detail |
|---|---|
| Intent | Operator-visible quorum-loss/restore transitions |
| Requirements | 8.1–8.3 |
Summary: ha_state_t gains _Atomic int quorum_lost (0 at init). New ha_log_quorum_transition(ha_state_t *ha): evaluate ha_has_write_quorum; on edge, atomically flip the flag and WARN once — loss: "HA quorum lost: connected=%d required=%d"; restore: "HA quorum restored: connected=%d required=%d" (8.1, 8.2). Call sites: the three standby_endpoints mutation points (promotion registration ha.c:531, RDHUP removal ha.c:3160-3166, on_standby_connected ha.c:3764+) plus the ha_dispatch_gate quorum-rejection branch as a backstop (flag check makes repeated rejections silent — 8.3). CAS on the flag guarantees once-per-transition under concurrency. Note: the promotion-registration anchor (ha.c:531) lives inside raft_cb_become_leader (ha.c:445-617), leader-role promotion code that ha-single-leader-role owns and rewrites wholesale — this call site must be re-anchored after ha-single-leader-role lands (or coordinated with that spec if this one is implemented first); see Revalidation Triggers.
Convergence Verification Suite (testsuite/, E2E)
Summary: regression pins for the four existing behaviors the umbrella marked "Done (verify)": full fan-out (repl_broadcast reaches every active standby — 4.1), snapshot-fallback decision (standby_lsn < oldest_wal_lsn → HA_REPL_SEND_SNAPSHOT — 6.1), re-inclusion mechanism (repl_complete_catchup clears in_catch_up, standby re-enters broadcast and quorum — 7.1), degraded-mode allowlist (gate admits status + replication opcodes without quorum — 9.1, 9.2). Plus the 3-node E2E extending ha-wal-replication task 5.4: confirmed jobs survive failover; a timeout-failed submit converges to a single outcome on all members; the demoted ex-primary restarts once and rejoins converged; status served during isolation (2.4 + live 9.1). E2E requires ha-single-leader-role landed and a live /xce-deploy-ha cluster.
Data Models
Domain Model (changes only)
- job_t (types.h wrapper): +
uint64_t submit_lsn— entry LSN of the job's submit record;0= unknown/exportable; in-memory only (recomputed at replay from the applied entry; never serialized — wire type unchanged) - ha_state_t: +
_Atomic int quorum_lost(transition edge detector); + per-standby_Atomic int catchup_inflight[MAX_HA_CANDIDATES](single-flight guard); +_Atomic int rejoin_reconciled(defensive once-guard) - process: saved argv (main.c static, NULL-terminated) — the restart vehicle
- Invariants: scheduler-bound exports contain only jobs with
submit_lsn <= committed_lsn(or 0); at most one continuation in flight per standby; at most one rejoin reconciliation per process lifetime; truncation always precedes restart
Error Handling
Error Strategy
- Self-restart exec failure → ERROR log +
abort()(FATAL) — a divergent ex-primary must never continue serving (locked constraint); under XCE_TESTING the fatal seam records instead of aborting - Truncation failure during demotion/rejoin → ERROR, then restart anyway: boot replay of an untruncated log is reconciled at the next rejoin (the reconciliation loop converges); never proceed as a serving node past a failed convergence step
- Continuation-task endpoint gone at run time → clear in-flight flag and return (the heartbeat fallback re-evaluates) — no error surfaced
- Egress predicate is infallible (atomic load + compare); tagging failures cannot occur on the success path (tag set only after a successful append)
- Quorum logging is observability-only: WARN once per transition, never an error path, never gates the operation outcome (the gate's existing
-XCE_ERR_NOT_READYbehavior is unchanged)
Monitoring
- WARN logs: demotion truncation range; rejoin reconciliation range; quorum lost/restored with counts. INFO: continuation scheduling at TRACE/INFO consistent with existing streaming logs. Metrics export out of scope (umbrella).
Testing Strategy
Unit Tests (testsuite/, XCE_TESTING)
test_demotion_convergence.c: become-follower from PRIMARY → hooks disabled before role store, truncation called withcommitted_lsncutoff, WARN logged,ha_restart_hookinvoked (1.1–1.3, 1.5, 1.6); exec-failure path invokes the fatal seam (1.4); become-follower from non-PRIMARY roles → no truncation/restart (regression); rejoin reconcile: tip > boundary → clamped cutoffmax(boundary, local committed)+ restart-once (2.1, 2.2); tip <= boundary → no action (2.3); second invocation in one lifetime → no second restart (2.1)test_egress_filter.c: live submit tagssubmit_lsn = written_lsn; replayed WAL_JOB_SUBMIT tags from entry LSN (3.3); job withsubmit_lsn > committed_lsnabsent fromcollect_jobs_committedand from the JOB_NEW push list, present aftercommitted_lsnadvances (3.1, 3.2);submit_lsn == 0exportable; plaincollect_jobs(status path) returns the uncommitted job (3.4)test_convergence_verification.c: walAck behind target schedules exactly one continuation (hook-counted viaha_repl_stream_wal_hook/xce_wal_stream_request_hook), duplicate ACKs while in flight schedule none (5.1, 5.2); ACK at parity callsrepl_complete_catchupand clearsin_catch_up(7.1); heartbeat decision path byte-equivalent for snapshot decisions (5.3, 6.1 pin);repl_broadcastreaches all active standbys, skips only in_catch_up/empty (4.1 pin); quorum transition logged once across repeated gate rejections, restore logged on reconnection (8.1–8.3); dispatch gate admits status (OPCode_get) and replication opcodes without quorum, rejects request-shaped opcodes only (OPCode_jobsub/jobcontrol/queuecontrol/hostcontrol/jobdispatch/join) and admits fact-shaped opcodes (OPCode_notify/update/setpreason/clrpreason) per ha-commit-barrier-rollout's fact allowlist (9.1, 9.2 pin)
Integration Tests (testsuite/)
- Demotion seam end-to-end: simulated PRIMARY with appended uncommitted suffix sees higher term → truncation + restart hook; a fresh replay over the surviving log yields committed-only state (1.7, 2.4 in-process half)
- Rejoin reconciliation through the cluster.c intake: deliver a CtrlHelloReply with a boundary below the local tip → reconcile fires; boundary at/above tip → join proceeds untouched
E2E (live cluster, extends ha-wal-replication task 5.4 — REQUIRES ha-single-leader-role landed)
- 3-node via
/xce-deploy-ha: submit confirmed jobs → kill primary → promoted standby owns all confirmed jobs; a submit forced to time out at the barrier converges to a single outcome on all members and is never retroactively reported successful; the demoted ex-primary restarts once and rejoins converged (2.4, 1.3, 2.1) - 3-node: isolate primary → submits fail, status still served, quorum-loss WARN present; heal → quorum-restored WARN, submits succeed (8.1, 8.2, 9.1 live)
Performance & Scalability
- Egress predicate: one atomic load + compare per job per export — negligible against existing per-job serialization
- Continuation: at most one in-flight stream per standby; scheduling O(1) on iomgr; streaming cost unchanged (same
ha_stream_wal_to_standby), just driven at ACK cadence instead of 3s heartbeats — catch-up latency improves by up to one heartbeat period per round - Demotion/rejoin restart: process restart cost accepted by design (rare role/lifecycle events; connections migrate to the new primary anyway — umbrella-locked rationale)
Migration Strategy
This spec is phase 4 (final) of the umbrella's hard-ordered landing. It depends on ha-commit-engine (committed_lsn semantics) being live; it is parallel-safe with ha-commit-barrier-rollout and ha-single-leader-role except that the 3-node E2E requires ha-single-leader-role landed (direct promotion — the demotion path asserted here is the post-COMMITTING-removal one). Despite that parallel-safety, implementation in roadmap order is recommended regardless: all four sub-specs touch ha_dispatch_gate and raft_cb_become_follower in disjoint but adjacent regions, and in-order landing avoids anchor churn and merge coordination (per cross-spec review 2026-06-11). Landing order inside the spec: egress filter (pure additive) → demotion convergence + self-restart → rejoin reconciliation (reuses the primitive) → ACK-driven catch-up → quorum observability → verification suite → E2E. Rollback trigger per umbrella: controller testsuite regression or live-cluster E2E failure; each step leaves the system no less consistent than before it (filter defaults open via submit_lsn == 0; demotion/rejoin only ever discard above the committed boundary).