# Brief: ha-election-raft-config ## Problem The current LSF-style priority-based election has three critical safety gaps: 1. **Config freshness not guaranteed**: A node with missing or stale config can be elected primary and serve incorrect cluster state to workers/schedulers. 2. **False self-elections during concurrent startup**: When pc13v1 starts while pc13v (the priority-0 primary) is still initializing, pc13v's peer listener port is not open yet. pc13v1's election probe gets ECONNREFUSED, falsely concludes the primary is absent, and self-elects. Both nodes declare themselves primary. 3. **No bootstrap path for genuine failover**: When a standby is promoted during real failover (original primary down), it has no mechanism to pull config from peer standbys if its local `conf/current` is missing. Reproduction from logs: - pc13v starts at 12:30:03, pc13v1 starts at 12:30:22 (19s later) - pc13v1 probes pc13v:9990 during election, times out within 3046ms - pc13v is healthy but still in `load_primary_config()` or early phase2 — listener not open - pc13v1 wins: "no higher-priority candidate responded" - pc13v1 calls `load_primary_config()` → "Primary has no config at '/tmp/xce/pc13v1/conf/current' (rc=-2)" → fatal exit ## Current State - **Election** (ha.c:243–306): LSF-style priority-based. Priority-0 always wins unconditionally; priority-N probes [0..N-1] and self-elects on timeout. No quorum. No config-version check. Logically correct for simple cases but unsafe for config freshness. - **Listener timing** (myapp.c:21–96): `bind()+listen()` happens inside `cluster_init_phase2()` AFTER election and config load. Creates a 2–3 second window where the primary is unreachable to probes. - **Config versioning** (config_repl.c): SHA-256 tagged, atomic writes, retention policy, staleness detection (`conf_stale` flag). Complete and correct. - **Config fetch** (config_repl.c:891–965): standby→primary pull framework exists; `do_one_config_fetch()` returns `-ENOSYS` (stub, Task 11 on branch). - **Bootstrap after failover**: not implemented. Design §14.4 deferred quorum consensus. - **Simultaneous primaries**: `ha_check_priority_stepdown()` (ha.c:322) has a term=0 exact check that fails to resolve races when both nodes increment term before heartbeat exchange. ## Availability Trade-off (CP chosen) This design chooses **Consistency over Availability** (CP in CAP terms): - A cluster of N nodes requires a **majority (≥(N+1)/2)** for both **election** and **write commits**. - **Election**: A minority partition cannot elect a primary. The cluster is unavailable until the majority partition is restored. - **Writes (WAL replication)**: Primary waits for majority standby ACKs before confirming a write (job submit, queue change, state update). In-flight writes that cannot reach majority are rejected. This adds **~1 LAN RTT per write** (~0.1–5ms depending on network). - **Zero data loss on failover**: A promoted standby is guaranteed to have all committed WAL entries because majority replication ensures durability before write ACK. - **Operational implication**: Plan for N≥3 nodes. A 2-node cluster has no meaningful majority; it is equivalent to single-node with a cold standby. For HPC, zero data loss (no lost job submissions, no stale queue state) outweighs the write-latency cost. ## Desired Outcome 1. **Raft-style quorum election (CP)**: A candidate must obtain ACKs from a strict majority of peers (≥(N+1)/2) before becoming primary. A minority partition cannot elect a primary. The cluster is unavailable until the majority partition is restored. 2. **Config freshness encoded in votes**: Each vote reply includes the peer's config tag and WAL LSN. The candidate with the freshest config (highest tag, then highest LSN) among quorum members wins tiebreaks. 3. **No-config nodes bootstrap after election (deferred fetch)**: A node with `config_tag=0` never initiates candidacy. It probes peers to determine whether config exists anywhere, but **defers the actual fetch until the primary is known** — the election proves who has the freshest state, so bootstrap fetches from the proven winner rather than speculatively guessing. Three sub-cases: - `tag=0`, peers have config → set `defer_config_fetch`, participate as voter only, fetch from winner after election - `tag=0`, all peers also `tag=0` → first-boot cluster, proceed to election as-is - `tag=0`, no peer reachable → retry: probe loop with exponential backoff (500ms→10s); log WARN every attempt; block indefinitely until a peer responds or admin kills 4. **Listener before election**: Peer listener socket opens before election so concurrent-startup probes reach an open port. False self-elections during overlapping startup are eliminated. 5. **Config freshness preserved through all paths**: A node always has a valid config before entering phase 2 — either from local disk (normal case) or fetched from the proven election winner after the election resolves (no-config case, deferred fetch in Change C). A quorum winner therefore always has `config_tag > 0` when peers do, and serves correct cluster state immediately after promotion with no additional bootstrap step. 6. **Most up-to-date primary**: Among all reachable nodes, only the one with the highest config_tag (then highest LSN) can become primary. A candidate that wins the vote count but sees a fresher responding peer yields back to standby — the fresher peer runs its own election and wins. Non-responding (partitioned or crashed) peers never block election. 7. **No simultaneous primaries**: Only one node can obtain a quorum at any given term. Term-based fencing prevents split-brain on partition heal. `voted_for` tracking (persisted to `ha-term.bin`) enforces one vote per term per node across restarts. 8. **Majority-commit WAL replication (CP writes)**: Primary waits for majority (≥(N+1)/2) standby ACKs before marking a WAL entry committed and confirming the write to the caller. In-flight writes that cannot reach majority within a timeout are rejected. Zero data loss on failover: promoted standby has all committed entries. 9. **Majority-commit config push (CP config)**: Config upgrades (`OP_upgradeConfig`) use the same `repl_broadcast()` + `repl_wait_majority_ack()` infrastructure as WAL replication — the primary waits for ≥(N+1)/2 standby ACKs before confirming a config change to the operator. Operator changes cannot be silently lost on failover. 10. **Hard SSL/cert gate**: Controller refuses to boot if TLS cert/key files are missing or unreadable. Operator-friendly error message with file path. 11. **Observable via logs**: Election results, vote tallies, quorum counts, config tag comparisons, bootstrap operations, WAL commit quorum status, config push quorum status, and cert validation logged at INFO or WARN severity. ## Approach **Replace LSF priority election with Raft-style quorum voting AND add majority-commit WAL replication for full CP durability. Config upgrades use the same majority-commit infrastructure for uniform CP semantics.** Eight focused changes: ### Change A — Move listener, iomgr, and vtaskmgr before election Extract `bind()+listen()`, `iomgr_init()`, and `vtaskmgr_init()` from `cluster_init_phase2()` so the full connection framework is running before election. `vtaskmgr_init()` uses the new `--vcpus N` CLI parameter (Change D item 1) instead of `cluster_config->vcpus`, removing the last config dependency on this startup block. New startup order in `myapp.c`: ``` validate_peer_count() ← Change D: odd-N check before any init validate_certs() ← Change D: cert pre-flight cluster_init_phase1() ← ha_init() reads ha-term.bin: restores term + voted_for ← conf_read_tag() reads config_version_tag from disk (0 if absent) ← Node now has durable identity before network is up create_ssl_context() iomgr_init() + iomgr_set_ssl_ctx() cluster_open_listener(port) ← bind + listen; register fd with iomgr vtaskmgr_init(vcpus) iomgr_start() vtaskmgr_start() atomic_store(ha.role, CTRL_INIT) ← node now answers OP_requestVote + OP_probeConfigTag bootstrap_config_if_needed() ← Change C: if config_tag==0, probe peers; defers fetch until primary known ha_wait_for_heartbeat_or_timeout() ← Raft: sem_timedwait(election_timeout + rand_jitter) ← heartbeat received → CTRL_STANDBY, skip election ← timeout fires → CTRL_ELECTING → am_i_primary_quorum() [primary or standby path] load_primary_config() / ha_connect_to_primary() cluster_init_phase2_rest() ← kvmgr, xstring, jobmgr, delaymgr, apiserver, etc. cluster_run() ``` With iomgr and vtaskmgr running before election, incoming `OP_requestVote` messages are handled by the normal dispatch switch — no special pre-framework path needed. Outgoing vote requests use the existing `connect_ssl_raw()` + `xce_encode()/xce_decode()` pattern (same as config fetch), keeping per-peer threads lightweight. **Full 4-state controller FSM (`ha_role_t`)**: The controller lifecycle is a single FSM stored in `c->ha.role` (atomic): ``` CTRL_INIT ──── timeout + jitter ──→ CTRL_ELECTING ──── quorum won ──→ CTRL_PRIMARY └─── heartbeat from │ │ existing primary ─→ └─── quorum lost ─→ CTRL_STANDBY ────┘ │ EPOLLRDHUP / heartbeat timeout │ CTRL_ELECTING ``` **Dispatch gate** (top of vtaskmgr switch, before any `case`): ```c switch (atomic_load(&c->ha.role)) { case CTRL_INIT: case CTRL_ELECTING: // Only vote + config-probe pass; all others get NOT_READY and retry (Change G) if (msg->op != OP_requestVote && msg->op != OP_probeConfigTag) { xce_send_error(task, XCE_ERR_NOT_READY, "controller not ready"); return; } break; case CTRL_STANDBY: // Primary-only ops rejected with XCE_ERR_STANDBY + redirect_host/port (Change G) break; case CTRL_PRIMARY: break; // full dispatch } ``` **State transition triggers**: - `CTRL_INIT → CTRL_ELECTING`: `ha_wait_for_heartbeat_or_timeout()` times out — no heartbeat received within `election_timeout + rand_jitter`. Different jitter per node (xorshift64 from `jitter_seed`) means nodes fire at staggered times, so the first candidate typically wins unopposed. This is Raft's primary split-vote prevention. - `CTRL_INIT → CTRL_STANDBY`: heartbeat or `OP_ctrlHello` received from an existing primary during the wait window — node skips election entirely. - `CTRL_ELECTING → CTRL_PRIMARY`: `am_i_primary_quorum()` returns 1. - `CTRL_ELECTING → CTRL_STANDBY`: `am_i_primary_quorum()` returns 0. - `CTRL_STANDBY → CTRL_ELECTING` (fast): `EPOLLRDHUP` on `primary_ctrl_fd`. - `CTRL_STANDBY → CTRL_ELECTING` (fallback): heartbeat timeout vtask fires — primary is alive on TCP but not sending heartbeats. Vtask is reset on each heartbeat received; fires after `election_timeout + rand_jitter` of silence. **`ha_wait_for_heartbeat_or_timeout()`** in `myapp.c`: ```c struct timespec dl = now_plus_ms(c->ha.config.election_timeout_ms + ha_rand_jitter(&c->ha)); int hb = (sem_timedwait(&c->ha.heartbeat_seen, &dl) == 0); if (hb) { atomic_store(&c->ha.role, CTRL_STANDBY); } else { atomic_store(&c->ha.role, CTRL_ELECTING); rc = am_i_primary_quorum(c); } ``` `ha.heartbeat_seen` is posted by the heartbeat dispatch handler when any heartbeat or `OP_ctrlHello` is received while `ha.role == CTRL_INIT`. ### Change B — Raft-style quorum voting in `am_i_primary()` Replace the current timeout-based probe with a full request-vote protocol: **New wire message: OP_requestVote** (Cap'n Proto) ```c struct RequestVoteRequest { term @0 :UInt64; // candidate's term candidate_id @1 :Text; // candidate's name (from --peers) config_tag @2 :UInt64; // candidate's config SHA-256 tag (0 if none) last_log_lsn @3 :UInt64; // candidate's WAL applied_lsn // NOTE: priority removed — Raft config_tag+LSN freshness fully determines winner; // jitter breaks split votes. Priority plays no role in quorum election. } struct RequestVoteReply { term @0 :UInt64; // peer's current term vote_granted @1 :Bool; // true = ACK peer_config_tag @2 :UInt64; // peer's config tag (for config comparison) peer_last_log_lsn @3 :UInt64; // peer's WAL LSN (for log comparison) } ``` **Election uses iomgr + vtaskmgr (FSM model)**: Since iomgr and vtaskmgr are running before election (Change A), vote requests are sent via iomgr's async connect path and replies arrive through the normal vtaskmgr dispatch switch. No election-specific threads are created. **New `ha_vote_entry_t`** struct (ha.h) — per-peer election slice; only meaningful as `ctx->votes[i]` inside `ha_elect_ctx_t`: ```c typedef struct { xce_candidate_t *peer; uint64_t my_term; // term sent in RequestVote uint64_t my_tag; // config_tag sent in RequestVote uint64_t my_lsn; // WAL LSN sent in RequestVote int vote_granted; // 1 = peer granted vote int replied; // 1 = peer responded (any reply, including denial) uint64_t peer_tag; // peer's config_version_tag from reply uint64_t peer_lsn; // peer's applied_lsn from reply // NOTE: no pthread_t — FSM uses iomgr async sends + vtaskmgr dispatch; // ha_elect_ctx_t.done semaphore blocks am_i_primary_quorum() } ha_vote_entry_t; ``` **New `ha_elect_ctx_t`** struct (ha.h) — holds all election state; single allocation via C99 flexible array member (one `calloc`, one `free`): ```c typedef struct { int n_peers; int votes_granted; // includes self-vote (starts at 1) int n_replied; // peers that responded (any reply) uint64_t max_resp_tag; // freshest config_tag seen across all replies uint64_t max_resp_lsn; // freshest lsn seen across all replies int result; // set before sem_post: 1=primary, 0=standby, <0=error sem_t done; // startup thread blocks here; dispatch posts when done ha_vote_entry_t votes[]; // C99 flexible array — n_peers-1 entries } ha_elect_ctx_t; // Allocate: calloc(1, sizeof(*ctx) + (n_peers-1)*sizeof(ctx->votes[0])) // Free: free(ctx) — single allocation, no separate votes free needed ``` **`am_i_primary_quorum()`** (ha.c) — initiates election, blocks until done: ```c int am_i_primary_quorum(cluster_t *c) { int n_peers = c->pcfg.n_candidates; if (n_peers == 1) return 1; uint64_t my_term = atomic_fetch_add(&c->ha.term, 1) + 1; ha_persist_term(c, my_term, c->myconf->name); ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + (n_peers-1)*sizeof(ctx->votes[0])); ctx->n_peers = n_peers; ctx->votes_granted = 1; // self-vote ctx->max_resp_tag = c->myconf->config_version_tag; ctx->max_resp_lsn = atomic_load(&c->persist.applied_lsn); sem_init(&ctx->done, 0, 0); atomic_store(&c->ha.elect_ctx, ctx); // dispatch reads this // Send RequestVote to all peers via iomgr (async non-blocking connects) for (int i = 0, j = 0; i < n_peers; i++) { if (strcmp(c->pcfg.candidates[i].name, c->myconf->name) == 0) continue; ctx->votes[j].peer = &c->pcfg.candidates[i]; ctx->votes[j].my_term = my_term; ctx->votes[j].my_tag = c->myconf->config_version_tag; ctx->votes[j].my_lsn = atomic_load(&c->persist.applied_lsn); iomgr_async_vote_request(c, &ctx->votes[j]); // non-blocking j++; } // Arm election timeout vtask — fires ha_elect_timeout() if peers don't reply vtaskmgr_schedule(c, c->ha.config.election_timeout_ms, ha_elect_timeout, ctx); sem_wait(&ctx->done); // startup blocks here; dispatch or timeout signals it int result = ctx->result; ha_elect_ctx_free(ctx); atomic_store(&c->ha.elect_ctx, NULL); return result; } ``` **`handle_request_vote_reply()`** — called from vtaskmgr dispatch switch on `OP_requestVoteReply`; accumulates votes and signals completion: ```c void handle_request_vote_reply(cluster_t *c, RequestVoteReply *reply, int peer_idx) { ha_elect_ctx_t *ctx = atomic_load(&c->ha.elect_ctx); if (!ctx) return; // no election in progress ha_vote_entry_t *v = &ctx->votes[peer_idx]; v->replied = 1; v->peer_tag = reply->peer_config_tag; v->peer_lsn = reply->peer_last_log_lsn; if (reply->vote_granted) { v->vote_granted = 1; ctx->votes_granted++; } if (v->peer_tag > ctx->max_resp_tag || (v->peer_tag == ctx->max_resp_tag && v->peer_lsn > ctx->max_resp_lsn)) { ctx->max_resp_tag = v->peer_tag; ctx->max_resp_lsn = v->peer_lsn; } ctx->n_replied++; int quorum = (ctx->n_peers + 1) / 2; // Signal when quorum achieved or all peers replied (or timed out) if (ctx->votes_granted >= quorum || ctx->n_replied == ctx->n_peers - 1) ha_elect_evaluate_and_signal(c, ctx); } ``` **`ha_elect_timeout()`** vtask — fires when `election_timeout_ms` expires: counts unreplied peers as non-respondents, calls `ha_elect_evaluate_and_signal()`. **`ha_elect_evaluate_and_signal()`** — applies gates, sets `ctx->result`, signals: - Defense-in-depth assertion (config_tag=0 BUG check) - Hard gate B (yield if responding peer has fresher data → `result=0`) - Quorum met → `result=1` - Quorum lost → schedule jitter vtask, retry once with incremented term; if second round also fails → `result=0`; finally `sem_post(&ctx->done)` **Vote handler on peers** (new function in ha.c): ```c void handle_request_vote(cluster_t *c, RequestVoteRequest *req, RequestVoteReply *reply) { pthread_mutex_lock(&c->ha.lock); uint64_t my_term = atomic_load(&c->ha.term); if (req->term < my_term) { // stale candidate — reject reply->term = my_term; reply->vote_granted = 0; goto done; } if (req->term > my_term) { // update term from candidate atomic_store(&c->ha.term, req->term); my_term = req->term; // Clear voted_for on new term transition c->ha.voted_for_term = 0; c->ha.voted_for_name[0] = '\0'; } // Catch-up gate (Clarification 6): behind nodes must not influence elections if (c->ha.in_catch_up) { reply->vote_granted = 0; goto fill_reply; // still send peer_config_tag/lsn so candidate has full picture } uint64_t my_tag = c->myconf->config_version_tag; uint64_t my_lsn = atomic_load(&c->persist.applied_lsn); int log_ok = (req->config_tag > my_tag) || (req->config_tag == my_tag && req->last_log_lsn >= my_lsn); // One vote per term: grant only if not yet voted, or voted for same candidate int already_voted = (c->ha.voted_for_term == my_term && strcmp(c->ha.voted_for_name, req->candidate_id) != 0); reply->vote_granted = (log_ok && !already_voted) ? 1 : 0; if (reply->vote_granted) { c->ha.voted_for_term = my_term; strncpy(c->ha.voted_for_name, req->candidate_id, XCE_MAXNAME_LEN - 1); ha_persist_term(c, my_term, req->candidate_id); // durable before reply } done: reply->term = my_term; reply->peer_config_tag = my_tag; reply->peer_last_log_lsn = my_lsn; pthread_mutex_unlock(&c->ha.lock); } ``` **Election safety extensions**: 1. **Term + `voted_for` persistence** (`ha-term.bin` in `{workdir}/`): New `ha_persist_term(cluster_t *c, uint64_t term, const char *voted_for)` writes a 4-field struct atomically (write + rename) before any vote is granted or term incremented. On startup, `ha_init()` reads `ha-term.bin` and restores `ha.term`, `ha.voted_for_term`, `ha.voted_for_name`. Without this, a restarting node resets to term=0 and can double-vote in a term it already participated in. 2. **New fields in `ha_state_t`** (`ha.h`): ```c uint64_t voted_for_term; // term of last granted vote char voted_for_name[XCE_MAXNAME_LEN]; // candidate name of last granted vote int primary_ctrl_fd; // persistent control channel fd (Change F; -1 = none) bool defer_config_fetch; // Change C: set when tag=0 and peers have config; // cleared after post-election fetch completes ``` 3. **Retire `ha_check_priority_stepdown()`** (ha.c:322–368): This function's guard `if (my_term > 0 || sender_term > 0) return` makes it permanently unreachable after the first quorum election (term is always ≥ 1 thereafter). Change B explicitly removes the call site from the heartbeat handler and deletes the function body. Term-based leadership is fully handled by the `reply.term > my_term` check in `handle_request_vote()` and the existing `ha_do_demote()` path. **Key properties**: - **Most up-to-date wins**: A candidate that won quorum still yields if any *responding* peer has higher config_tag or (same tag, higher LSN). Non-responding peers never block election — only reachable peers with fresher state can cause a yield. - **Config freshness hard gate**: Yielding due to fresher peer returns 0 (standby, not fatal). Only `config_tag == 0` with peers having `config_tag > 0` is fatal (-ENODATA), because that means the node has never been configured at all. - **Split vote prevention (primary)**: Each node waits `election_timeout + rand_jitter` before candidacy (Change A `ha_wait_for_heartbeat_or_timeout()`). Different jitter values stagger candidacy attempts so the first node typically wins unopposed — the same xorshift64 `jitter_seed` used elsewhere. This is Raft's fundamental split-vote prevention, not just recovery. - **Split vote recovery (safety net)**: If a split vote still occurs inside `ha_elect_evaluate_and_signal()`, a second attempt is scheduled with `rand_jitter` delay and incremented term. If that also fails, node returns 0 (standby). This safety net handles the rare case where multiple nodes have identical jitter values. - **No priority tiebreak**: Priority is not used in Raft quorum election. When config_tag and LSN are identical across all peers, the jitter-based pre-election timeout is the tiebreaker. The `ha.my_priority` field is preserved in `ha_state_t` for backward compatibility but plays no role in election outcome. ### Change C — Pre-election config bootstrap (deferred fetch) A node with `config_tag=0` is a config-seeker, not a candidate. Before calling `am_i_primary_quorum()`, `bootstrap_config_if_needed()` determines whether config exists among peers, but **defers the actual fetch until the primary is known** — avoiding a speculative download from a peer that may not win the election. **Rationale**: Fetching from the highest-tag *reachable* peer before election can waste I/O: a fresher peer may start late, win the election, and push its config anyway. Since the election already proves who has the freshest state, bootstrap should defer to that proof rather than guessing. New function `bootstrap_config_if_needed()` in config_repl.c (called from myapp.c): ```c void bootstrap_config_if_needed(cluster_t *c) { if (c->myconf->config_version_tag != 0) return; // already have config int retry_ms = 500; for (;;) { uint64_t best_tag = 0; bool any_reachable = false; for (int i = 0; i < c->pcfg.n_candidates; i++) { if (strcmp(c->pcfg.candidates[i].name, c->myconf->name) == 0) continue; uint64_t peer_tag = 0; int rc = xce_probe_config_tag(c->pcfg.candidates[i].host, c->pcfg.candidates[i].port, &peer_tag); if (rc != 0) continue; // peer unreachable — skip any_reachable = true; if (peer_tag > best_tag) best_tag = peer_tag; } if (!any_reachable) { WARN("No config and no peer reachable — retrying in %dms (kill to abort)", retry_ms); usleep((useconds_t)retry_ms * 1000); retry_ms = MIN(retry_ms * 2, 10000); continue; } if (best_tag == 0) { INFO("All peers also have no config — first-boot cluster, proceeding"); return; // all-zero cluster: first boot, proceed to election as-is } // Config exists among peers — defer fetch until primary is known. // The election will prove who has the freshest state. INFO("Config exists among peers (best tag=%016" PRIx64 ") — " "deferring fetch until primary is known", best_tag); c->ha.defer_config_fetch = true; return; } } ``` The function never returns an error. If no peer is reachable, it retries with exponential backoff. If all peers are `tag=0`, it returns for first-boot election. If config exists among peers, it sets `defer_config_fetch` and returns — the node will fetch from the proven winner after the election resolves. `xce_probe_config_tag()` is a lightweight new RPC (single round-trip) that returns the peer's current `config_version_tag` without transferring the full config payload. Reuses the existing SSL connection setup pattern (`connect_ssl_raw()`). **Cap'n Proto schema for `OP_probeConfigTag`**: ```c struct ProbeConfigTagRequest { name @0 :Text; // requester's node name (for logging on peer side) } struct ProbeConfigTagReply { config_tag @0 :UInt64; // peer's current config_version_tag (0 if no config) } ``` Answered by all nodes in any FSM state (including `CTRL_INIT`) — the dispatch gate explicitly allows `OP_probeConfigTag` through before the controller is ready for normal requests (Change A). Note: the actual config transfer (`OP_replConfig`) does NOT need to be role-agnostic — with deferred fetch, `do_one_config_fetch()` is called post-election when the serving peer is already in `CTRL_STANDBY` or `CTRL_PRIMARY` and full dispatch is active. Only the probe op needs the `CTRL_INIT` exemption. Integrated into `myapp.c` (before election): ```c bootstrap_config_if_needed(&c); // returns immediately: tag>0, first-boot, or defer // The tag=0 node participates in the election as a voter but never wins // against nodes with tag>0 (log_ok gate ensures this). It votes, discovers // the winner via voted_for, and fetches from the proven primary. rc = am_i_primary_quorum(&c); if (rc < 0) return rc; if (c.ha.defer_config_fetch && rc != 1) { // Fetch from the proven winner (known via voted_for or heartbeat source). // Guard rc != 1: a winning node has tag>0 so defer_config_fetch is never // set for it, but the explicit guard prevents double load_primary_config() // if logic shifts during implementation. do_one_config_fetch(c->ha.primary_host, c->ha.primary_port, 0); load_primary_config(&c); c->ha.defer_config_fetch = false; } if (rc == 1) { rc = load_primary_config(&c); // load into memory (already on disk) if (rc != 0) return rc; atomic_store(&c->ha.role, CTRL_PRIMARY); } ``` ### Change D — Startup pre-flight checks + ASAN leak fix `main.c`, `src/controller/cluster.c`, `lib/netio.c`, `ha.c` 1. **`--vcpus N` CLI parameter** (`main.c`, `src/controller/cluster.c`): Add a `--vcpus N` flag parsed in `main.c` alongside the existing `--peers` and `--name` flags. Default: `8` when not specified. Values `< 1` are rejected at startup with `"ERROR: --vcpus must be ≥ 1"` and exit `-EINVAL`. Remove the `vcpus` field from `cluster_config_t` (currently read from the config file). Pass the parsed value directly to `vtaskmgr_init()` — this removes `vtaskmgr_init()`'s dependency on `cluster_config`, enabling it to run before election (Change A). **Removed from cluster config schema**: the `vcpus:` key in the config file is no longer read. Existing configs that include it receive a logged deprecation warning (`"WARN: 'vcpus' in config file is deprecated — use --vcpus N instead"`) but the controller does not fail. **Rationale**: CPU count is a per-host capacity decision, not a cluster-wide policy. Different controller nodes in the same cluster may have different hardware. Keeping vcpus in the shared config forces all nodes to use the same value and couples an infrastructure tunable to the cluster state config file. 2. **Peer count validation in `main.c`** (before `cluster_init_phase1()`): The `--peers` list must contain exactly 1 node (single-node) or an **odd number ≥ 3** (HA mode). Even numbers cannot form a strict majority and are rejected at startup: ```c int n = pcfg.n_candidates; if (n != 1 && n % 2 == 0) { fprintf(stderr, "ERROR: --peers must list an odd number of controllers (1, 3, 5, ...).\n" " %d controllers cannot form a majority. Use 1 for single-node\n" " or add/remove one controller to reach an odd count.\n", n); return -EINVAL; } ``` This removes the need for the `n_peers == 2` special case inside `am_i_primary_quorum()` — the quorum formula `(N+1)/2` is correct for all validated (odd) values of N. 3. **Cert pre-flight in `main.c`** (before `myapp_run()`): explicit `access()` check on `{workdir}/certs/xce.key` and `{workdir}/certs/xce.crt`. On failure: print operator-friendly `"ERROR: TLS cert not found: "` and exit `-ENOENT`. Controller never starts without valid certs — same hard-prerequisite model as config. 4. **Fix ASAN leak in `ha_fini()` (ha.c:451)**: add `SSL_CTX_free(ha->ssl_ctx)` call. This closes the ~5320-byte direct+indirect leak shown in the pc13v1 ASAN output. ### Change E — Majority-commit WAL replication `src/controller/wal.c` (or `persist.c`), `src/controller/ha.c`, `src/controller/cluster.c` **Principle**: Primary does not ACK a write to its caller until ≥(N+1)/2 standbys have confirmed they wrote the WAL entry to disk. This guarantees any promoted standby has all committed entries — zero data loss on failover. **New wire message: OP_walAck** (Cap'n Proto): ```c struct WalAckRequest { term @0 :UInt64; // primary's current term (for stale-detection) lsn @1 :UInt64; // WAL entry LSN being ACKed } struct WalAckReply { term @0 :UInt64; // standby's term (primary steps down if reply.term > mine) lsn @1 :UInt64; // highest LSN standby has durably written success @2 :Bool; } ``` **Primary-side commit protocol** (`persist.c` / `wal.c`): 1. Write WAL entry to local disk (existing) 2. Fan-out `OP_walAck` to all connected standbys in parallel (async send) 3. Wait for ≥(N+1)/2 − 1 standby ACKs (own write counts as 1) within `commit_timeout_ms` (configurable, default 5000ms) 4. On quorum ACK: mark entry committed, return success to caller 5. On timeout without quorum: return `-ETIMEDOUT` to caller; entry is written locally but not committed — next primary will re-evaluate on WAL replay **Standby-side WAL write** (existing replication path extended): - On receiving replicated WAL entry: write to disk, fsync, send `OP_walAck` reply - No change to standby application logic **Backpressure**: Primary maintains a per-standby `pending_ack_count`. If a standby falls more than `max_pending_acks` (default 64) entries behind, it is removed from the active replication set and reconnects via existing catch-up path. **Single-node mode**: quorum = 1, commit is immediate (own write = majority). No change. **Clarification 1 — Durability boundary (which entry types block on majority-commit)**: Not all WAL entry types have equal durability requirements. The rule: | Entry type | Commit model | Rationale | |---|---|---| | `WAL_JOB_SUBMIT` | Majority-commit (blocking) | Job loss on failover is visible to user | | `WAL_JOB_START` / `WAL_JOB_FINISH` | Majority-commit (blocking) | State transitions must survive failover | | `WAL_QUEUE_CHANGE` | Majority-commit (blocking) | Queue config changes must be durable | | `WAL_HOST_STATE` | Best-effort async | Reconstructed from worker reconnect on failover | | `WAL_CHECKPOINT_REF` | Best-effort async | Checkpoint is self-contained; ref is advisory | The existing `persist_wal_sync_append()` path (used for `WAL_JOB_SUBMIT` today with ANY-standby wait) is extended to use majority quorum. The existing `persist_wal_async()` path remains fire-and-forget for best-effort types. No new caller API — entry type determines which path is taken inside `persist.c`. **Clarification 2 — `committed_lsn` data model**: Two new fields added to `persist_t` (persist.h): ```c _Atomic uint64_t committed_lsn; // highest LSN majority-ACKed; safe to serve/trim uint64_t applied_lsn; // existing: highest LSN written locally (may exceed committed) ``` `committed_lsn` is advanced by `repl_wait_majority_ack()` after quorum is reached. On promotion, the new primary replays WAL from its checkpoint up to `committed_lsn` only — entries above `committed_lsn` are discarded (never committed to majority, so no client received success for them). WAL trim guard updated: trim threshold = `committed_lsn`, not `applied_lsn`. Catch-up standbys (in_catch_up == 1) are excluded from quorum counting — they are not yet in the active replication set. This means a cluster with 2-of-3 nodes catching up cannot make progress until at least one catch-up completes. This is the correct CP behavior: prefer unavailability over serving uncommitted state. **Clarification 3 — `repl_broadcast()` prerequisite**: `repl_broadcast()` (repl.c:183–196) is currently a stub. Majority-commit requires a real broadcast implementation. This is co-developed with Change E — they are the same work unit. Change E scope explicitly includes: - Implement `repl_broadcast()` with per-standby TCP send via existing iomgr pipeline - Implement `repl_wait_majority_ack()` replacing the existing `repl_wait_ack()` (any→majority) - Add per-standby send error callback: failed send → decrement active standby count, trigger catch-up reconnect (existing path) If `repl_broadcast()` stub lands separately before Change E, Change E still works — it replaces the stub. No ordering dependency beyond Task 11 (config fetch). **New function `persist_truncate_wal_above(persist_t *p, uint64_t cutoff_lsn)`** (`persist.c`): Removes all WAL entries with LSN > `cutoff_lsn` from local storage. Called in two contexts (Clarifications 4 and 5). Implementation: scan WAL segment files from newest to oldest; truncate the file containing `cutoff_lsn` at the byte offset immediately after that entry; delete any segment files that start above `cutoff_lsn`. Updates `p->applied_lsn = cutoff_lsn` atomically after truncation. Must be idempotent (calling twice with same cutoff is safe). New function — does not currently exist in `persist.c`. **Clarification 4 — `committed_lsn` must be piggybacked on heartbeats**: A promoted standby needs to know where to truncate its WAL. Without this it only has `applied_lsn`, which may include uncommitted orphan entries written by the old primary but never majority-ACKed. - Extend `CtrlHeartbeat` Cap'n Proto struct with `committed_lsn @N :UInt64` - Standbys store it in `ha.last_known_committed_lsn` (`_Atomic uint64_t`, init 0) on each heartbeat receipt - On promotion, immediately after `am_i_primary_quorum()` returns 1: ```c persist_truncate_wal_above(&c->persist, c->ha.last_known_committed_lsn); c->persist.committed_lsn = c->ha.last_known_committed_lsn; ``` - First-boot failover (no heartbeat ever received): `last_known_committed_lsn = 0`, so WAL replay starts from checkpoint only — correct and safe **Clarification 5 — Log divergence on standby re-join**: The existing catch-up path assumes the standby is simply *behind* the primary (a clean subset). After a primary crash, a standby may have orphaned WAL entries above the new primary's `committed_lsn` — entries the old primary replicated but never majority-ACKed. Replaying those entries would corrupt the standby's state. Catch-up handshake extension (Change E scope): 1. Primary sends its `committed_lsn` in the catch-up initiation reply 2. Standby calls `persist_truncate_wal_above(&c->persist, primary_committed_lsn)` before requesting WAL replay — discards any orphaned entries above that boundary 3. Standby sets `applied_lsn = primary_committed_lsn` as the new replay base 4. Normal WAL catch-up (existing mechanism) proceeds from there **Clarification 6 — Catch-up standbys excluded from voting (not just quorum count)**: Clarification 2 excludes `in_catch_up==1` standbys from the majority quorum count. They must also be prevented from *granting* votes — a behind-node's stale LSN could tip an election to a less-fresh candidate. In `handle_request_vote()`, before the `log_ok` freshness check: ```c if (c->ha.in_catch_up) { reply->vote_granted = 0; // still populate peer_config_tag/peer_last_log_lsn — candidate needs the full picture goto fill_reply; } ``` **Clarification 7 — `applied_lsn` in vote request must be post-fsync**: `RequestVoteRequest.last_log_lsn` is sourced from `atomic_load(&c->persist.applied_lsn)`. This is correct only if `applied_lsn` is advanced *after* `fsync()` completes in `persist.c`. A node that claims an LSN it has not durably written could win an election and then fail to replay entries it advertised. Verify in `persist.c` before implementing Change B — if `applied_lsn` is advanced pre-fsync anywhere, fix that first. ### Change F — Persistent standby↔primary control channel **Files**: `src/controller/ha.c`, `src/controller/myapp.c`, `src/controller/cluster.c`, `src/controller/repl.c` **Background**: Standbys currently detect primary failure through `ha_check_heartbeat_nocluster()` (ha.c:380–405), which polls a `last_heartbeat` timestamp with a 10s floor. Failover detection latency is up to 10 seconds. Additionally, each config fetch and WAL replication message opens a new TCP+SSL connection (`connect_ssl_raw()`, config_repl.c:685–775), adding per-message handshake latency for WAL streaming (Change E). **Changes**: 1. **Standby discovers and connects to primary** — new `ha_find_and_connect_to_primary(cluster_t *c)` (called in `myapp.c` standby path after election). Opens a TCP+SSL connection to the primary peer and registers the fd with epoll `EPOLLIN | EPOLLRDHUP`. The connection is stored in `ha_state_t` as `ha->primary_ctrl_fd`. On connect, the standby sends an extended `OP_ctrlHello` (item 5) carrying identity and state. The primary registers the standby, initiates catch-up, and begins streaming any missing WAL before the standby enters the active replication set. **Primary discovery algorithm** (how the standby finds the winner's address): Two startup paths lead here with different amounts of prior knowledge: - **Heartbeat path** (`CTRL_INIT → CTRL_STANDBY`): `ha_wait_for_heartbeat_or_timeout()` received a heartbeat/`OP_ctrlHello` from an existing primary. The sender's host/port is in the packet. Store immediately as `ha.primary_host/port`. No further discovery needed — connect directly. - **Election path** (`CTRL_ELECTING → CTRL_STANDBY`): lost `am_i_primary_quorum()`. Discovery uses a three-tier strategy exploiting Raft's structural property that the majority voted for the winner: ``` Tier 1 (fast path — O(1) for ≥(N+1)/2 nodes): If ha.voted_for_name is set: try ha_connect_to_primary(voted_for peer) on success: store ha.primary_host/port, done on XCE_ERR_NOT_READY: peer still electing — backoff, retry tier 1 Tier 2 (redirect — O(1) once any majority member connected): Probe remaining peers (random order) with OP_ctrlHello A CTRL_STANDBY peer that already knows the primary responds with XCE_ERR_STANDBY + redirect_host/port (same field as Change G) Follow redirect immediately — connect to that address Tier 3 (blind scan fallback — for split-vote minorities): If redirect not received: try remaining peers sequentially A CTRL_PRIMARY peer accepts OP_ctrlHello normally → connected A CTRL_INIT/CTRL_ELECTING peer returns XCE_ERR_NOT_READY → skip+retry Repeat with 100ms → 5s exponential backoff until primary found ``` **Why this converges quickly**: In a 3-node cluster, at least 2 nodes voted for the winner. Those 2 connect via Tier 1 immediately. The 3rd probes either of them (Tier 2), gets a redirect, and connects. Total: 2 direct + 1 redirect = all standbys connected with at most one extra probe per minority node. In a 5-node cluster, at least 3 connect via Tier 1; the remaining 2 get redirected. **`ha.primary_host/port` update**: set on successful connect; cleared to empty on `EPOLLRDHUP` (primary disconnect). During the election window it is empty; standbys receiving `OP_ctrlHello` while `ha.primary_host` is empty reply with `XCE_ERR_STANDBY` and no redirect (graceful — probing node falls through to Tier 3). **No new message types**: the redirect uses the existing `XCE_ERR_STANDBY` error reply fields already extended in Change G (`redirect_host`, `redirect_port`). The standby dispatch gate (already returning `XCE_ERR_STANDBY` for primary-only ops) populates these fields from `ha.primary_host/port` when they are known. 2. **Standby join synchronization — full state sync on connect** (cluster.c, ha.c, repl.c): A standby with stale WAL cannot safely promote — its in-memory state (jobs, queues, resources) would be out of date, and promoted writes would replay entries clients already saw as committed. Every standby must be fully current before it enters the quorum. **Extended `OP_ctrlHello` Cap'n Proto schema**: ```c struct CtrlHelloRequest { name @0 :Text; // peer name (from --peers) key @1 :Data; // cluster auth key: SHA-256(cluster_name + shared_secret) // shared_secret read from myconf->cluster_key (new config field) // primary rejects connections where HMAC does not match its own applied_lsn @2 :UInt64; // standby's current applied_lsn (0 if fresh node) config_tag @3 :UInt64; // standby's current config_version_tag (0 if none) } struct CtrlHelloReply { committed_lsn @0 :UInt64; // primary's current committed_lsn needs_snapshot @1 :Bool; // true if standby's WAL predates oldest primary segment config_tag @2 :UInt64; // primary's current config_version_tag } ``` **New wire messages for sync**: ```c struct SnapshotTransfer { snapshot_lsn @0 :UInt64; // LSN of this snapshot (= checkpoint_lsn after fresh checkpoint) payload @1 :Data; // serialized snapshot blob (from persist_checkpoint()) } struct WalCatchupEntry { lsn @0 :UInt64; // WAL entry LSN type @1 :UInt8; // WAL entry type (WAL_JOB_SUBMIT etc.) payload @2 :Data; // WAL entry payload } ``` `OP_snapshotTransfer` and `OP_walCatchupEntry` flow on the persistent control channel fd — no new connections. **Primary-side `handle_ctrlhello()` sequence** (new handler in cluster.c): ``` 1. Extract name, applied_lsn, config_tag from CtrlHelloRequest 2. repl_add_standby(&c->repl, ep, host, port) 3. needs_snap = repl_needs_snapshot(&c->repl, applied_lsn, oldest_wal_lsn()) 4. Reply: CtrlHelloReply{c->persist.committed_lsn, needs_snap, myconf->config_version_tag} 5. repl_start_catchup(&c->repl, ep, applied_lsn) // sets standby's in_catch_up=1 6. If needs_snapshot: persist_checkpoint(&c->persist) // flush in-memory state to disk send OP_snapshotTransfer(snapshot_lsn, blob) applied_lsn = snapshot_lsn // advance standby's effective base 7. Streaming loop — runs until standby is fully current: target_lsn = atomic_load(&c->persist.committed_lsn) // read current live value stream WAL entries [applied_lsn+1 → target_lsn] via OP_walCatchupEntry wait for OP_walAck with acked_lsn >= target_lsn if atomic_load(&c->persist.committed_lsn) > target_lsn: applied_lsn = target_lsn; continue loop // new entries committed, catch up again // standby is now at current committed_lsn — exit loop 8. repl_complete_catchup(&c->repl, ep) // in_catch_up=0; standby enters live replication 9. If standby config_tag != primary config_tag: push_config_to_standbys(c, blob, blen, tag) ``` **`repl_broadcast()` skips `in_catch_up==1` standbys**: while the streaming loop (step 7) is in progress, `repl_broadcast()` does not send to this standby. The loop itself handles delivery of all committed entries — including entries committed after the handshake — by re-reading `committed_lsn` on each iteration. The loop exits only when the standby's `acked_lsn` equals the *current* `committed_lsn`, at which point `repl_complete_catchup()` fires and live broadcast takes over. There is no window where entries can be missed. **Standby-side sequence** (after receiving CtrlHelloReply in ha.c / myapp.c): ``` 1. Store primary_committed_lsn = reply.committed_lsn 2. persist_truncate_wal_above(&c->persist, primary_committed_lsn) // Clarification 5 3. ha.last_known_committed_lsn = primary_committed_lsn // Clarification 4 4. ha.in_catch_up = 1 // deny votes during sync 5. If reply.needs_snapshot: wait for OP_snapshotTransfer write received blob as local snapshot file load_snapshot(&c->persist, path, snapshot_lsn) applied_lsn = snapshot.snapshot_lsn 6. Receive OP_walCatchupEntry stream; call apply_wal_entry() for each 7. Send OP_walAck after each entry (or configurable batch): OP_walAck{acked_lsn = last applied LSN} 8. When primary sends no more OP_walCatchupEntry (catch-up loop ended on primary side): ha.in_catch_up = 0 // standby now in live replication; votes granted normally ``` `apply_wal_entry()` is the same WAL replay function used during startup recovery — it applies a single WAL entry to the in-memory state (jobs, queues, resources). Must exist or be extracted from the startup replay path before this change lands. **`repl_needs_snapshot()` already exists** in repl.c — compares `standby_applied_lsn` against `oldest_wal_lsn` (the trim floor). If standby is behind the trim floor, `persist_checkpoint()` is called on the primary to produce a fresh snapshot, which is then streamed in full. This is the only path where `persist_checkpoint()` is called on-demand rather than on schedule. **`push_config_to_standbys()` is declared** in config_repl.h; config sync at join handles the case where a standby was offline during a config upgrade. 2. **Heartbeat runs on control channel** — primary sends periodic heartbeats on the controller↔controller channel (in addition to existing worker heartbeats). Standby updates `last_heartbeat` on receipt, but the epoll path is the fast lane. 3. **Replace timestamp polling with dual-trigger failover detection** — `ha_check_heartbeat_nocluster()` (ha.c:380–405) is retired. Two complementary paths: - **Fast path (`EPOLLRDHUP`)**: TCP disconnect detected immediately; `primary_ctrl_fd` closed and nulled before `am_i_primary_quorum()` is called. Sub-second latency. - **Fallback path (heartbeat timeout vtask)**: armed after each heartbeat is received (`vtaskmgr_schedule(election_timeout + rand_jitter, ha_heartbeat_timeout_cb)`); reset on every subsequent heartbeat. Fires when primary is silent but TCP is alive — covers network partitions that keep TCP open, primary overload, or missed heartbeat bursts. On fire: close+null `primary_ctrl_fd`, set `CTRL_ELECTING`, call `am_i_primary_quorum()`. 4. **WAL streaming over persistent channel (Change E complement)** — `repl_broadcast()` (repl.c:183–196) sends WAL entries on the persistent control channels established by each standby rather than opening per-entry connections. No new connection setup per WAL write. **Role flip on promotion**: When a standby wins election and becomes primary, it must close and discard its `primary_ctrl_fd` (the old connection to the now-dead primary) *before* calling `am_i_primary_quorum()` triggers the role transition. After promotion, the new primary accepts `OP_ctrlHello` connections from the remaining standbys — the role of `primary_ctrl_fd` inverts from "my connection out" to "their connections in", tracked in a per-standby fd table rather than a single field. **Reconnect behavior**: On primary_ctrl_fd disconnect, standby closes and nulls `primary_ctrl_fd` first, then calls `am_i_primary_quorum()`. If it becomes primary, the old primary_ctrl_fd is discarded. If it remains standby, it reconnects with exponential backoff (100ms, 200ms, 400ms, max 5s) until the new primary is reachable. **Single-node mode**: `ha_connect_to_primary()` is a no-op when `n_peers == 1`. **Key properties**: - Sub-second failover detection (`EPOLLRDHUP` vs 10s timeout) - No per-entry TCP+SSL handshake overhead for WAL replication - Single liveness signal: connection alive = peer alive - Backward-compatible: standby reconnect on disconnect triggers election (same semantics as today, faster latency) ### Change G — libxce client-side retry for transient controller errors **Files**: `src/lib/` (libxce — client request/connection layer) **Background**: With `CTRL_ELECTING` gating requests during the election window (Change A), and standbys already returning `XCE_ERR_STANDBY` to misdirected requests, every daemon (worker, scheduler) needs to handle these transient errors gracefully. Without built-in retry in libxce, each daemon would need its own ad-hoc loop — fragile and inconsistent. **Changes**: 1. **Retry policy in libxce's client request path** (the function wrapping `xce_encode()` + send + recv + `xce_decode()`): - On `XCE_ERR_NOT_READY`: back off and retry against same endpoint - On `XCE_ERR_STANDBY`: **redirect** — the standby includes the current primary's `host:port` in the error payload when known (`ha.primary_host/port`). libxce reconnects directly to the redirected endpoint instead of blind backoff. When primary is unknown (e.g. during startup), falls back to normal retry. - On TCP-level `ECONNREFUSED` or `ETIMEDOUT`: reconnect and retry (handles the window before `cluster_open_listener()` runs on a starting peer) - Non-retriable errors (`XCE_ERR_AUTH`, `XCE_ERR_INVALID`, `XCE_ERR_NOENT`, `XCE_ERR_PERM`, etc.): surface immediately to caller **`XCE_ERR_STANDBY` wire extension**: add optional `redirect_host` (Text) and `redirect_port` (UInt16) fields to the error reply Cap'n Proto struct. Standbys populate these when `ha.primary_host` is non-empty; clients that don't understand the new fields simply retry with backoff (backward-compatible). 2. **Retry configuration** (sensible defaults, overridable per client): ```c typedef struct { int max_retries; // default 20 int initial_retry_ms; // default 50 int max_retry_ms; // default 5000 double backoff_multiplier; // default 2.0 (exponential) } xce_retry_config_t; ``` Defaults cover a full election round (up to 20 × 50ms–5000ms) without requiring callers to configure anything. Daemons that need tighter or looser bounds pass a custom `xce_retry_config_t`. 3. **Transparent to callers**: existing daemon code makes one `xce_request()` call; libxce handles retry internally. No changes needed in worker, scheduler, or apiserver code. **Key properties**: - Decouples daemon startup order — a worker that starts before the primary is ready simply waits for the retry window to expire rather than crashing or looping manually - `XCE_ERR_STANDBY` retry enables transparent primary failover: after failover, the next request to the old primary (now standby) gets `XCE_ERR_STANDBY`, libxce retries against the same host/port and the new primary responds - Backoff prevents thundering-herd when 50 workers restart simultaneously after a failover ### Change H — Majority-commit config push `src/controller/config_repl.c`, `src/controller/repl.c` **Principle**: Config upgrades (`OP_upgradeConfig`) use the same majority-commit infrastructure as WAL replication (Change E). The primary does not ACK a config change to the operator until ≥(N+1)/2 standbys have confirmed durable write. **Why this matters**: Without majority-commit, a primary could accept a config change, ACK to the operator, then crash. The new primary serves the old config, and the operator's change is silently lost. For strict CP, config writes must have the same durability guarantee as WAL writes — even though they are orders of magnitude rarer. **Changes**: 1. **Reuse Change E infrastructure** — `repl_broadcast()` (sends to all connected standbys on persistent channels) and `repl_wait_majority_ack()` (blocks until ≥(N+1)/2 ACKs or `commit_timeout_ms`) already built for WAL. Both functions accept a message-type parameter so they can serve WAL (`OP_walAck`) and config (`OP_upgradeConfigAck`) ACKs without duplication. Zero new infrastructure. 2. **`push_config_to_standbys()` extended** (config_repl.c): ```c int push_config_to_standbys(cluster_t *c, const char *blob, size_t len, uint64_t tag) { // 1. Write locally (existing atomic write+rename — unchanged) int rc = config_repl_write_local(c, blob, len, tag); if (rc != 0) return rc; // 2. Fan-out OP_upgradeConfig via repl_broadcast (same channel as WAL) rc = repl_broadcast(c, OP_upgradeConfig, blob, len); if (rc != 0) return rc; // 3. Wait for ≥(N+1)/2 standby ACKs (own write = 1 vote) rc = repl_wait_majority_ack(c, c->ha.config.commit_timeout_ms); // 4. On quorum: advance committed_config_tag and return success if (rc == 0) { atomic_store(&c->myconf->committed_config_tag, tag); } return rc; // -ETIMEDOUT on failure — operator retries } ``` 3. **Standby-side config ACK** — on receiving `OP_upgradeConfig`, standby: - Writes config blob to disk (existing atomic write path) - Loads config into memory - Sends `OP_upgradeConfigAck` with `acked_tag = written_tag` - ACK semantics identical to `OP_walAck` — durable write complete 4. **`OP_upgradeConfigAck` Cap'n Proto**: ```c struct UpgradeConfigAck { term @0 :UInt64; // standby's term (primary steps down if ack.term > mine) acked_tag @1 :UInt64; // config tag standby has durably written success @2 :Bool; } ``` 5. **`committed_config_tag` field** added to cluster config state (analogous to `committed_lsn` for WAL). Advanced only on majority ACK. Stored alongside `config_version_tag` in durable state. On promotion, a new primary serves only configs up to `committed_config_tag`. **Piggybacked on heartbeats** (same as `committed_lsn` — Clarification 4): standbys store the latest value in `ha.last_known_committed_config_tag` so a promoted standby knows the safe boundary without an extra round-trip. First-boot promotion (no heartbeat received): value is 0, standby serves whatever config it loaded from disk — correct for first-boot where no upgrade has been committed. **Key properties**: - Same `commit_timeout_ms` as WAL (configurable, default 5000ms) - Single-node mode: quorum=1, commit is immediate (own write = majority) - Config upgrades are operator-initiated and rare — the blocking ACK wait adds negligible overhead to cluster operations - Catch-up standbys (`in_catch_up==1`) are excluded from quorum count, consistent with WAL majority rules - If commit times out, operator gets `-ETIMEDOUT` and can retry — libxce retry (Change G) handles this transparently for the API server ## Scope - **In**: - Change A: Extract `cluster_open_listener()`, `iomgr_init()`, and `vtaskmgr_init()` from phase2; call all three before election in myapp.c so the full connection framework is running during election (incoming `OP_requestVote` handled by normal dispatch switch); add `CTRL_ELECTING` role + pre-dispatch gate returning `XCE_ERR_NOT_READY` for non-vote requests during election window - Change B: Full Raft-style quorum voting in `am_i_primary_quorum()` - New `OP_requestVote` wire protocol (Cap'n Proto request + reply) - `handle_request_vote()` handler on all peers with `voted_for` enforcement - Config tag + LSN freshness comparison in vote grant and candidate yield gate - Hard gate A (fatal): refuse primary if config_tag=0 and any peer has tag>0 - Hard gate B (yield): winner yields if any *responding* peer has fresher data - Split vote retry with jitter (one retry after timeout) - Term + `voted_for` persistence to `ha-term.bin` (survives restart) - New `ha_state_t` fields: `voted_for_term`, `voted_for_name`, `primary_ctrl_fd` - Retire `ha_check_priority_stepdown()` (permanently unreachable after first election) - Change C: `bootstrap_config_if_needed()` pre-election bootstrap — probes peers for config tags, defers fetch until primary is known (avoids speculative download from a peer that may not win the election); tag=0 node participates as voter only; new `xce_probe_config_tag()` lightweight RPC; role-agnostic `handle_config_request()` so peers can serve config to a bootstrapping node - Change D: `--vcpus N` CLI parameter + remove `vcpus` from cluster config (deprecation warning for existing configs); odd-N peer-count validator at startup (`-EINVAL` on even count); cert pre-flight check in `main.c`; `SSL_CTX_free` ASAN fix in `ha.c` - Change E: Majority-commit WAL replication — `OP_walAck` protocol, primary commit gating, standby ACK path, backpressure, `commit_timeout_ms` config - Change F: Persistent standby↔primary control channel — `ha_connect_to_primary()`, `EPOLLRDHUP`-based failover detection, heartbeat on control channel, WAL streaming over persistent connection - Change G: libxce client-side retry — exponential backoff on `XCE_ERR_NOT_READY`, `XCE_ERR_STANDBY`, `ECONNREFUSED`, `ETIMEDOUT`; transparent to daemon callers; configurable via `xce_retry_config_t` with safe defaults - Change H: Majority-commit config push — `OP_upgradeConfig` uses the same `repl_broadcast()` + `repl_wait_majority_ack()` infrastructure as WAL replication (Change E). Primary waits for ≥(N+1)/2 standby ACKs before confirming a config upgrade to the operator. Reuses `commit_timeout_ms`. Zero new infrastructure cost. - Unit and integration tests for all eight changes - **Out**: - Raft snapshot protocol — existing dual-zarray COW is orthogonal and unchanged - Joint consensus for cluster membership changes — candidate list is static - do_one_config_fetch() stub wiring (Task 11) — prerequisite; must land first or co-developed - mTLS between controllers — trusted-LAN model with server-side TLS is sufficient ## Boundary Candidates 1. **Election protocol** — full replacement of `am_i_primary()` with quorum voting 2. **Config wire protocol** — new OP_requestVote, OP_probeConfigTag, OP_upgradeConfigAck; existing OP_replConfig used for post-election deferred fetch 3. **Primary startup path** — bootstrap integration in myapp.c 4. **Listener lifecycle** — extracted from phase2 to phase1 ## Out of Boundary - Standby fetch path (`fetch_config_from_primary()`) — untouched - Config format, versioning, or storage layout — untouched - Raft snapshot protocol — existing dual-zarray COW is orthogonal - Joint consensus for cluster membership changes — candidate list is static - mTLS — trusted-LAN model with server-side TLS is sufficient ## Upstream / Downstream - **Upstream**: Task 11 (`do_one_config_fetch()` stub) on persistent_ha branch — with deferred fetch (Change C), the probe-only path in `bootstrap_config_if_needed()` does not call `do_one_config_fetch()`. Task 11 is hit only at the post-election deferred fetch step in `myapp.c`. If Task 11 is not yet landed, the deferred fetch returns `-ENOSYS` and the bootstrapping standby fails to load config — a fatal but clearly logged error. Both can be co-developed; Task 11 must land before or with Change C for a functional cluster. - **Downstream**: Phase 3 hardening (membership changes, leader transfer), ops tooling for election/commit observability, monitoring metrics for WAL replication lag. ## Existing Spec Touchpoints - **Adjacent**: none of the active specs (resval-id-decoupling, scheduler-controller-msg-gap, worker-join-refactor) touch config replication, HA election, or cluster init. ## Constraints - C (gnu17), no C++, no new external dependencies. - ASAN build must pass (`nix build .#xcecore_debug`). - **`--peers` must list 1 or an odd number ≥ 3 controllers.** Even counts are rejected at startup with `-EINVAL`. This simplifies quorum math: `(N+1)/2` is exact for all valid N; no special-case branches needed inside `am_i_primary_quorum()`. - **Static membership**: the candidate list is fixed at startup from `--peers` and never changes while the cluster is running. No dynamic join/leave of controller peers. Worker and scheduler nodes may still join dynamically — only controller peer membership is static. This eliminates the need for joint consensus (Raft §6) and locks the quorum denominator for the lifetime of the cluster. - Single-node mode unaffected: quorum=1 everywhere (election, WAL commit = own write). - TDD required: failing test first, then implementation. - All new C test cases go in `src/controller/testsuite/` before implementation. - `make test` must pass without a live server. - `commit_timeout_ms` must be configurable (default 5000ms) to allow tuning per deployment latency. - **WAL write stall during standby catch-up (operational constraint)**: In a 3-node cluster, quorum for WAL commit is 2 (primary + 1 standby). Between primary winning election and the first standby completing `repl_complete_catchup()`, the active replication set has 0 standbys. Every `persist_wal_sync_append()` call blocks for `commit_timeout_ms` and returns `-ETIMEDOUT`. This is CP-correct behavior — the cluster is unavailable until quorum is formed. Operators should expect a stall of up to `commit_timeout_ms` per write during the initial catch-up window on first boot or after a restart. Jobs submitted during this window receive a retriable error; libxce retry (Change G) handles this transparently for workers and schedulers. ## Deployment Reference ### Controller node topology | Cluster size | Failure tolerance | WAL ACKs needed | Recommendation | |---|---|---|---| | 1 node | 0 | self only | dev/test only | | 3 nodes | 1 failure | 1 standby ACK | minimum for production | | 5 nodes | 2 failures | 2 standby ACKs | recommended for critical HPC | | 7 nodes | 3 failures | 3 standby ACKs | rarely justified; adds latency | ### Job submission throughput (Raft overhead only) Basis: LAN environment, 5 controller nodes, quorum = 3 (primary + 2 standby ACKs). **Critical path per `WAL_JOB_SUBMIT`:** ``` client → primary: RTT/2 + primary WAL fsync primary → 2 standbys: RTT/2 + standby WAL fsync (parallel, overlaps with above) 2nd ACK → primary: RTT/2 primary confirms: RTT/2 ───────────────────────────────────────────────── Total: 1 LAN RTT + max(fsync_primary, fsync_standby) ``` | Parameter | Optimistic | Conservative | |---|---|---| | LAN RTT (controller↔controller) | 0.1 ms | 0.5 ms | | NVMe fsync latency | 0.05 ms | 0.2 ms | | Effective commit latency | ~0.2 ms | ~0.7 ms | **Throughput estimates:** | Submit model | Commit latency | Jobs/sec | |---|---|---| | Serial single stream | 0.3–1 ms | 1,000–3,300 | | 10 concurrent clients | 0.5 ms | ~20,000 | | 100 concurrent clients | 0.5 ms | ~200,000 (non-Raft bottlenecks dominate before this) | **Practical HPC ceiling**: 5,000–20,000 jobs/sec before jobmgr queue locking and in-memory state mutations become the constraint. **5-node vs 3-node write latency**: negligible. Majority-commit waits for the **2nd-fastest** ACK out of 4 standbys (5-node) vs the **1st** out of 2 (3-node). Under uniform LAN latency the difference is sub-millisecond. The extra two nodes cost nothing in write throughput but double the failure tolerance. **When Raft becomes a bottleneck**: automated pipelines submitting >10,000 short jobs/sec. Mitigation: batch multiple job submits into a single WAL entry at the API layer before this design makes that necessary.