45 KiB
Design: HA Config Bootstrap & Raft-Style Quorum Election
Overview
This feature eliminates three critical safety gaps in the XCE controller's LSF-style priority-based election: unconfigured nodes can win elections and serve stale cluster state; concurrent-startup nodes falsely self-elect because the peer listener is not yet open; and a promoted standby with missing local config has no bootstrap path. The fix replaces priority election with a Raft-style quorum protocol that encodes config freshness in votes, moves the listener before election, and adds majority-commit durability for both WAL writes and config upgrades.
Purpose: Delivers full CP high-availability to cluster operators — zero data loss on failover, no split-brain on concurrent startup, and no silent config loss on primary crash. The design chooses Consistency over Availability (CAP-CP): a minority partition cannot elect a primary; the cluster waits until majority is restored.
Impact: Replaces am_i_primary() and ha_check_heartbeat_nocluster() with Raft quorum election and EPOLLRDHUP-based failover; extends the controller lifecycle from a 2-state (CTRL_PRIMARY/CTRL_STANDBY) to a 4-state FSM adding CTRL_INIT and CTRL_ELECTING; adds majority-commit gating to persist_wal_sync_append() and push_config_to_standbys(); establishes a persistent TLS control channel between each standby and the primary; adds transparent retry to libxce.
Goals
- Raft-style quorum election with config-freshness tiebreaking (CP semantics, no minority-partition primaries)
- Zero data loss on failover via majority-commit WAL and config replication
- Sub-second failover detection via
EPOLLRDHUP+ heartbeat timeout vtask - Transparent recovery for unconfigured nodes joining an existing cluster (deferred config fetch)
- Transparent client retry in libxce — worker/scheduler/apiserver code unchanged
Non-Goals
- Raft snapshot protocol (existing dual-zarray COW is orthogonal and unchanged)
- Joint consensus for dynamic controller membership changes (
--peerslist is static at startup) - mTLS between controllers (server-side TLS sufficient for trusted-LAN model)
- Config file format or storage layout changes
- Worker/scheduler daemon protocol changes
Boundary Commitments
This Spec Owns
- Controller election protocol: full replacement of
am_i_primary()witham_i_primary_quorum() - Controller FSM: 4-state lifecycle (
CTRL_INIT,CTRL_ELECTING,CTRL_PRIMARY,CTRL_STANDBY) and all state transitions - Pre-election startup sequence: listener binding, iomgr/vtaskmgr init before election
- Config bootstrap:
bootstrap_config_if_needed(),xce_probe_config_tag(), deferred fetch after election - Startup validation:
--vcpus NCLI, even-N peer count rejection, TLS cert pre-flight, ASAN leak fix - Majority-commit WAL:
repl_broadcast(),repl_wait_majority_ack(),committed_lsn,persist_truncate_wal_above() - Majority-commit config push:
OP_upgradeConfigAck,committed_config_tag, heartbeat piggybacking - Persistent control channel:
ha_find_and_connect_to_primary(),EPOLLRDHUPfailover, WAL streaming over channel, three-tier primary discovery - Election state durability:
ha-term.bin,ha_persist_term(),voted_for_term/nameacross restarts - libxce client retry:
xce_retry_config_t, retry wrapper,XCE_ERR_STANDBYredirect fields in wire protocol - Wire protocol extensions:
OP_requestVote,OP_probeConfigTag,OP_walAck,OP_upgradeConfigAck,OP_snapshotTransfer,OP_walCatchupEntry - Structured log observability for all new code paths (election, bootstrap, WAL commit, config push, FSM transitions)
Out of Boundary
apply_wal_entry()— must be extracted from the startup WAL replay path as a prerequisite to Change F; not authored in this specpersist_checkpoint()trigger policy — existing on-demand call inrepl_needs_snapshot()path; unchanged- Worker/scheduler daemon code — libxce retry is transparent; no caller code changes
- apiserver Go tier — no changes
scheduler-controller-msg-gapspec andworker-join-refactorspec — orthogonal; no shared files- Dynamic controller membership changes (joint consensus)
- mTLS between controllers
Allowed Dependencies
iomgr(epoll/EPOLLIN/EPOLLRDHUP): P0 — control channel and dispatch loopvtaskmgr: P0 —vtaskmgr_init(int nvcpus)confirmed to take vcpus directly, no cluster_config dependencyendpoint_connect()/connect_ssl_raw(): P0 — SSL connection setup; TCP connect phase is blocking; vote request connections require per-peer pthreadsrepl_add/remove/start_catchup/complete_catchup/needs_snapshot/on_ack/trim_lsn: P0 — existing repl slot management reused unchangeddo_one_config_fetch(): P1 — wired on branch (2aae45f); deferred fetch depends on itxce_encode()/xce_decode()/xce_request(): P0 — wire protocol encodingha_do_promote()/ha_do_demote(): P0 — role transitions, reusedsrc/schema/controller.capnp: P0 — new opcodes appended after existing @31xcelog.hINFO/WARN/ERROR macros: P2 — observability
Revalidation Triggers
- Changes to
ha_state_tfield layout require re-checking all ha.c callers - Changes to
RequestReplyorCtrlHeartbeatcapnp structs require checking all decode sites - Moving
cluster_open_listener()before election changes startup order; any code assuming phase2 runs before network is up must be re-verified committed_lsncross-cutspersist_t,repl.c, andha_state_t; changes to any require re-checking the othersha-term.binformat change requires migration or version field
Architecture
Existing Architecture Analysis
The current controller has a 2-state election: am_i_primary() spawns per-peer pthreads that probe higher-priority peers and self-elect on timeout. cluster_init_phase2() contains bind()+listen(), iomgr_init(), and vtaskmgr_init() — all run after election, creating a 2–3 second window where the port is unreachable to election probes.
Confirmed gaps from codebase inspection (see research.md §1 for full inventory):
ctrl_role_thas onlyCTRL_PRIMARY=0,CTRL_STANDBY=1ha_state_tlacks:voted_for_term,voted_for_name,primary_ctrl_fd,defer_config_fetch,elect_ctx,last_known_committed_lsn,heartbeat_seen,last_known_committed_config_tagrepl_broadcast()is a stub returning 0 without sending;repl_wait_majority_ack()does not existpersist_tlackscommitted_lsn;persist_truncate_wal_above()does not existXCE_ERR_STANDBYreply has noredirect_host/portfields — must be added to capnp schemaha-term.bindoes not exist;ha_init()always starts from term=0ha_check_priority_stepdown()is permanently unreachable after first quorum election (term always ≥ 1)ha_check_heartbeat_nocluster()uses 10s timestamp polling — replaced by dual-trigger failover
Architecture Pattern & FSM
Selected pattern: 4-state FSM with Raft-style majority-quorum consensus (CP). Each startup step gates the next.
stateDiagram-v2
[*] --> CTRL_INIT : startup complete listener open
CTRL_INIT --> CTRL_STANDBY : heartbeat received from existing primary
CTRL_INIT --> CTRL_ELECTING : election timeout fires
CTRL_ELECTING --> CTRL_PRIMARY : quorum won and no fresher peer
CTRL_ELECTING --> CTRL_STANDBY : quorum lost or fresher peer yields
CTRL_STANDBY --> CTRL_ELECTING : EPOLLRDHUP or heartbeat timeout vtask
CTRL_PRIMARY --> CTRL_STANDBY : higher-term reply received
graph TB
subgraph Startup
V[validate_peer_count + validate_certs] --> P1[cluster_init_phase1]
P1 --> Net[iomgr_init + cluster_open_listener + vtaskmgr_init]
Net --> Boot[bootstrap_config_if_needed]
Boot --> Wait[ha_wait_for_heartbeat_or_timeout]
end
subgraph Election
Wait --> Quorum[am_i_primary_quorum]
Quorum --> Vote[OP_requestVote per peer thread]
Vote --> Eval[ha_elect_evaluate_and_signal]
end
subgraph Replication
WAL[persist_wal_sync_append] --> Bcast[repl_broadcast]
Bcast --> MAck[repl_wait_majority_ack]
MAck --> Commit[advance committed_lsn]
end
subgraph ControlChannel
SB[CTRL_STANDBY] --> Conn[ha_find_and_connect_to_primary]
Conn --> Epoll[EPOLLIN + EPOLLRDHUP]
Epoll --> Sync[state sync WAL streaming]
end
Technology Stack
| Layer | Choice | Role | Notes |
|---|---|---|---|
| Runtime | C gnu17 | All new code | No C++ |
| Wire format | Cap'n Proto | 6 new opcodes in controller.capnp |
Appended after existing @31 |
| SSL | OpenSSL (existing) | Vote request and probe connections | Reuses connect_ssl_raw() |
| Consensus | Raft subset (custom) | Leader election + majority-commit | No external library; bounded scope justified by static membership and no snapshot protocol need |
| IPC | iomgr epoll | EPOLLIN + EPOLLRDHUP on control channel |
EPOLLRDHUP for sub-second failover |
| Persistence | Flat binary (ha-term.bin) |
Term + voted_for across restarts | Atomic write+rename (existing pattern) |
| Build | Meson + Nix | capnp schema regeneration | src/schema/controller.capnp |
File Structure Plan
Directory Structure
src/
├── schema/
│ └── controller.capnp # Modified: 6 new opcodes + message structs
├── controller/
│ ├── ha.h # Modified: CTRL_INIT/CTRL_ELECTING enum values;
│ │ # ha_vote_entry_t, ha_elect_ctx_t types;
│ │ # new ha_state_t fields; ha_persist_term() decl
│ ├── ha.c # Modified: replace am_i_primary() -> am_i_primary_quorum();
│ │ # add handle_request_vote(), handle_request_vote_reply(),
│ │ # ha_wait_for_heartbeat_or_timeout(), ha_elect_evaluate_and_signal(),
│ │ # ha_find_and_connect_to_primary(), ha_persist_term();
│ │ # retire ha_check_priority_stepdown(), ha_check_heartbeat_nocluster();
│ │ # SSL_CTX_free ASAN fix in ha_fini()
│ ├── persist.h # Modified: add committed_lsn to persist_t
│ ├── persist.c # Modified: persist_truncate_wal_above() (new);
│ │ # upgrade persist_wal_sync_append() to repl_wait_majority_ack()
│ ├── repl.h # Modified: repl_wait_majority_ack() decl;
│ │ # pending_ack_count in standby_conn_t
│ ├── repl.c # Modified: implement repl_broadcast() (was stub);
│ │ # repl_wait_majority_ack() (new); backpressure; catch-up exclusion
│ ├── config_repl.h # Modified: bootstrap_config_if_needed() and xce_probe_config_tag() decls
│ ├── config_repl.c # Modified: bootstrap_config_if_needed() (new);
│ │ # xce_probe_config_tag() (new);
│ │ # upgrade push_config_to_standbys() to majority-commit
│ ├── cluster.h # Modified: remove vcpus from cluster_config_t
│ ├── cluster.c # Modified: extract cluster_open_listener() from phase2;
│ │ # extended handle_ctrlhello(); OP_snapshotTransfer and
│ │ # OP_walCatchupEntry dispatch handlers
│ ├── myapp.c # Modified: new startup order (listener before election);
│ │ # bootstrap_config_if_needed() call; deferred fetch integration
│ ├── main.c # Modified: --vcpus N CLI arg; even-N peer count validation;
│ │ # cert pre-flight access() check
│ └── testsuite/
│ ├── test_ha_election.c # New: quorum election, vote protocol, FSM transitions
│ ├── test_ha_bootstrap.c # New: bootstrap_config_if_needed, probe RPC, deferred fetch
│ ├── test_ha_durability.c # New: ha-term.bin persist/restore, CRC corruption
│ ├── test_persist_truncate.c # New: persist_truncate_wal_above, boundary segment, idempotency
│ ├── test_repl_majority.c # New: repl_broadcast, repl_wait_majority_ack, backpressure
│ └── test_ha_startup.c # New: startup validation (vcpus, peer count, cert pre-flight)
└── lib/
├── request.h # Modified: xce_retry_config_t; xce_request_with_retry() decl
├── request.c # Modified: retry wrapper; redirect handling; backoff logic
└── testsuite/
└── test_libxce_retry.c # New: retry logic, backoff, redirect, non-retriable errors
Modified Files Summary
| File | Change Type | Requirements |
|---|---|---|
src/schema/controller.capnp |
Extend: 6 opcodes + structs | 2, 3, 5, 6, 7, 8 |
src/controller/ha.h |
Extend: enum + types + fields | 1, 2, 3, 5, 6, 9 |
src/controller/ha.c |
Major: replace election core; add control channel | 1, 2, 3, 6, 9 |
src/controller/persist.h |
Extend: committed_lsn | 5 |
src/controller/persist.c |
New function + upgrade sync path | 5 |
src/controller/repl.h |
Extend: standby_conn_t field + decl | 5, 8 |
src/controller/repl.c |
Implement stubs + add majority logic | 5, 6, 8 |
src/controller/config_repl.h |
Extend: two new function decls | 3, 8 |
src/controller/config_repl.c |
New functions + upgrade push | 3, 8 |
src/controller/cluster.h |
Remove vcpus field | 4 |
src/controller/cluster.c |
Extract listener; extend ctrlhello handler | 1, 6 |
src/controller/myapp.c |
New startup sequence | 1, 3 |
src/controller/main.c |
CLI validation + pre-flight | 4 |
src/lib/request.h |
New type + decl | 7 |
src/lib/request.c |
Retry wrapper | 7 |
System Flows
Flow 1: Controller Startup & Election
sequenceDiagram
participant Main as main.c
participant MyApp as myapp.c
participant HA as ha.c
participant Cluster as cluster.c
participant Config as config_repl.c
Main->>Main: validate_peer_count odd-N check
Main->>Main: validate_certs access()
Main->>MyApp: myapp_run()
MyApp->>Cluster: cluster_init_phase1 reads ha-term.bin via ha_init
MyApp->>Cluster: iomgr_init + cluster_open_listener + vtaskmgr_init
Note over Cluster: listener open BEFORE election
MyApp->>Config: bootstrap_config_if_needed
Note over Config: tag=0 and peers have config? set defer_config_fetch and return
MyApp->>HA: ha_wait_for_heartbeat_or_timeout
alt heartbeat received in window
HA->>MyApp: set CTRL_STANDBY skip election
else election timeout fires
HA->>HA: am_i_primary_quorum send OP_requestVote per peer
HA->>MyApp: CTRL_PRIMARY or CTRL_STANDBY
end
alt defer_config_fetch and not primary
MyApp->>Config: do_one_config_fetch from proven winner
end
MyApp->>Cluster: cluster_init_phase2_rest kvmgr jobmgr apiserver etc.
Flow 2: Quorum Election Vote Protocol
sequenceDiagram
participant Cand as Candidate ha.c
participant PA as Peer A ha.c
participant PB as Peer B ha.c
Cand->>Cand: increment term persist ha-term.bin
Cand->>PA: OP_requestVote term config_tag last_lsn via per-peer thread
Cand->>PB: OP_requestVote term config_tag last_lsn via per-peer thread
PA->>PA: handle_request_vote check voted_for and log_ok
PA->>Cand: OP_requestVoteReply granted=true peer_tag peer_lsn
PB->>PB: handle_request_vote check voted_for and log_ok
PB->>Cand: OP_requestVoteReply granted=true peer_tag peer_lsn
Cand->>Cand: ha_elect_evaluate_and_signal
Note over Cand: votes_granted ge quorum AND no fresher peer? CTRL_PRIMARY
Key decisions: fresher-peer yield gate fires when any responding peer has higher config_tag or equal tag with higher LSN; non-responding peers never block election. Split-vote triggers one retry with incremented term and randomized jitter delay.
Flow 3: WAL Majority-Commit
sequenceDiagram
participant Caller as Job submit caller
participant Persist as persist.c
participant Repl as repl.c
participant SA as Standby A
participant SB as Standby B
Caller->>Persist: persist_wal_sync_append WAL_JOB_SUBMIT
Persist->>Persist: write + fsync local advance applied_lsn
Persist->>Repl: repl_broadcast OP_walAck payload over persistent channel
Repl->>SA: WAL entry on control channel fd
Repl->>SB: WAL entry on control channel fd
SA->>Repl: OP_walAck lsn=N
Repl->>Persist: repl_wait_majority_ack quorum reached
Persist->>Persist: advance committed_lsn to N
Persist->>Caller: return 0 success
SB->>Repl: OP_walAck lsn=N arrives later counted but not blocking
Entry types WAL_HOST_STATE and WAL_CHECKPOINT_REF bypass this flow via persist_wal_async() (best-effort, unchanged).
Flow 4: Standby Failover Detection and Re-election
sequenceDiagram
participant IOM as iomgr epoll
participant HA as ha.c standby
participant Peers as Peer controllers
IOM->>HA: EPOLLRDHUP on primary_ctrl_fd
HA->>HA: close primary_ctrl_fd set CTRL_ELECTING
HA->>HA: persist_truncate_wal_above last_known_committed_lsn
HA->>Peers: am_i_primary_quorum new term OP_requestVote
alt quorum won
HA->>HA: CTRL_PRIMARY accept inbound control channels
else quorum lost
HA->>Peers: ha_find_and_connect_to_primary three-tier discovery
HA->>HA: CTRL_STANDBY
end
Fallback trigger: heartbeat timeout vtask fires after election_timeout_ms + rand_jitter of silence on an open TCP channel (covers network partitions that keep TCP alive but suppress heartbeats).
Requirements Traceability
| Requirement | Summary | Components | Key Interface | Flow |
|---|---|---|---|---|
| 1.1–1.4 | Listener + iomgr/vtaskmgr before election; dispatch gate for init/electing states | cluster.c, ha.c, myapp.c |
cluster_open_listener(), vtaskmgr dispatch gate |
Flow 1 |
| 2.1–2.12 | Quorum election: voted_for enforcement, freshness gate, split-vote retry, single-node shortcircuit | ha.c |
am_i_primary_quorum(), handle_request_vote(), OP_requestVote |
Flow 2 |
| 3.1–3.5 | Pre-election config probe, deferred fetch, retry backoff | config_repl.c, myapp.c |
bootstrap_config_if_needed(), xce_probe_config_tag(), OP_probeConfigTag |
Flow 1 |
| 4.1–4.4 | --vcpus CLI, even-N rejection, cert pre-flight, ASAN fix | main.c, ha.c |
CLI argp, access(), SSL_CTX_free |
Flow 1 |
| 5.1–5.8 | Majority-commit WAL: committed_lsn, broadcast, ack-wait, backpressure, truncation | persist.c, repl.c, ha.c |
repl_wait_majority_ack(), persist_truncate_wal_above(), OP_walAck |
Flow 3 |
| 6.1–6.8 | Persistent control channel: EPOLLRDHUP failover, state sync, catch-up, three-tier discovery | ha.c, cluster.c, repl.c |
ha_find_and_connect_to_primary(), extended OP_ctrlHello, OP_snapshotTransfer, OP_walCatchupEntry |
Flow 4 |
| 7.1–7.6 | Client retry: backoff, redirect, non-retriable passthrough | request.c (libxce) |
xce_request_with_retry(), xce_retry_config_t, redirect fields in OP_requestVoteReply |
— |
| 8.1–8.5 | Majority-commit config push, committed_config_tag, heartbeat piggybacking | config_repl.c, repl.c |
push_config_to_standbys(), OP_upgradeConfigAck |
Flow 3 analog |
| 9.1–9.3 | Term + voted_for durability across restarts via ha-term.bin | ha.c |
ha_persist_term(), ha_init() read path |
— |
| 10.1–10.5 | Structured logs for election outcome, bootstrap, WAL commit, config push, FSM transitions | All new code paths | xcelog.h INFO/WARN macros |
All flows |
Components and Interfaces
Summary Table
| Component | File(s) | Intent | Req Coverage | Key Dependencies |
|---|---|---|---|---|
| Controller FSM | ha.h, ha.c |
4-state lifecycle + election logic | 1, 2, 9 | iomgr P0, vtaskmgr P0 |
| Config Bootstrap | config_repl.c |
Pre-election probe + deferred fetch | 3 | do_one_config_fetch P1 |
| Startup Validation | main.c, ha.c |
CLI, peer count, cert pre-flight, ASAN | 4 | argp, access() |
| WAL Majority-Commit | persist.c, repl.c |
committed_lsn, broadcast, ack-wait | 5 | iomgr P0 |
| Persistent Control Channel | ha.c, cluster.c |
TCP+SSL channel, EPOLLRDHUP, state sync | 6 | repl.c P0, iomgr P0 |
| Majority-Commit Config | config_repl.c |
Config push with majority ACK | 8 | repl_broadcast P0 |
| Term Durability | ha.c |
ha-term.bin persist/restore | 9 | atomic write+rename |
| Client Retry | request.c |
Transparent retry + redirect | 7 | XCE_ERR_* codes P0 |
| Wire Protocol | controller.capnp |
New opcodes and message types | 2, 3, 5, 6, 7, 8 | capnp toolchain P0 |
Controller Core (ha.h / ha.c)
Controller FSM
| Field | Detail |
|---|---|
| Intent | 4-state FSM governing election, standby, and primary lifecycle; owns all election logic and term durability |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1–2.12, 9.1–9.3 |
Responsibilities & Constraints
- Owns
ctrl_role_tenum — addCTRL_INIT=2,CTRL_ELECTING=3(values above existing 0/1) - Single allocation for
ha_elect_ctx_tvia C99 flexible array — onecalloc, onefree - All role transitions via
atomic_store(&c->ha.role, ...)— no lock held across transition ha_check_priority_stepdown()andha_check_heartbeat_nocluster()are deleted in this spec
New ha_state_t fields:
uint64_t voted_for_term; // Req 9: term of last granted vote
char voted_for_name[XCE_MAXNAME_LEN]; // Req 9: candidate of last granted vote
int primary_ctrl_fd; // Req 6: persistent channel fd (-1 = none)
bool defer_config_fetch; // Req 3: tag=0 node defers fetch to post-election
_Atomic void *elect_ctx; // Req 2: ha_elect_ctx_t* during election; NULL otherwise
_Atomic uint64_t last_known_committed_lsn; // Req 5: from primary heartbeats
sem_t heartbeat_seen; // Req 1: posted by heartbeat handler during CTRL_INIT
uint64_t last_known_committed_config_tag; // Req 8: from primary heartbeats
New types (ha.h):
typedef struct {
xce_candidate_t *peer;
uint64_t my_term;
uint64_t my_tag;
uint64_t my_lsn;
int vote_granted;
int replied;
uint64_t peer_tag;
uint64_t peer_lsn;
} ha_vote_entry_t;
typedef struct {
int n_peers;
int votes_granted; // starts at 1 (self-vote)
int n_replied;
uint64_t max_resp_tag;
uint64_t max_resp_lsn;
int result; // 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
// Allocate: calloc(1, sizeof(*ctx) + (n_peers-1) * sizeof(ctx->votes[0]))
} ha_elect_ctx_t;
ha_persist_term() contract:
- Precondition: called before any vote is granted or term incremented
- Action: writes
ha_term_file_tatomically to{workdir}/ha-term.bin(write + rename) - Postcondition: file reflects current state; durable before any network reply
Dispatch gate (vtaskmgr switch, req 1.3):
// CTRL_INIT or CTRL_ELECTING: only OP_requestVote and OP_probeConfigTag pass through
switch (atomic_load(&c->ha.role)) {
case CTRL_INIT:
case CTRL_ELECTING:
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 return XCE_ERR_STANDBY + redirect_host/port when known
break;
case CTRL_PRIMARY:
break;
}
Contracts: Service [x] / State [x]
Implementation Notes
- Risk:
endpoint_connect()has a blocking TCP connect phase. Vote request sends require per-peer pthreads (not pure iomgr async). Election contextha_elect_ctx_taccumulates replies; threads post toctx->donesemaphore when quorum is reached or all peers replied. - Integration:
ha_wait_for_heartbeat_or_timeout()usessem_timedwait(&c->ha.heartbeat_seen, &deadline)wheredeadline = now + election_timeout_ms + rand_jitter. Heartbeat handler postsheartbeat_seenonly whenha.role == CTRL_INIT. voted_forenforcement: per-term, one vote per node. Ifreq->term > my_term: update term, clearvoted_for_term/voted_for_name. Ifreq->term < my_term: deny. If already voted for a different candidate in same term: deny.
Persistence Layer (persist.h / persist.c)
WAL Majority-Commit
| Field | Detail |
|---|---|
| Intent | Gate job/queue WAL writes on majority-standby durability; new committed_lsn field and truncation function |
| Requirements | 5.1–5.8 |
New persist_t field:
_Atomic uint64_t committed_lsn; // highest majority-ACKed LSN; invariant: committed_lsn <= applied_lsn
persist_truncate_wal_above(persist_t *p, uint64_t cutoff_lsn) contract:
- Precondition:
cutoff_lsn <= p->applied_lsn; may be called after promotion or standby re-join - Action: find WAL segment containing
cutoff_lsnby filename (filename encodes start LSN as hex); scan entries in that segment sequentially until LSN > cutoff_lsn; truncate at that byte offset; delete all segments starting above cutoff_lsn; atomically updatep->applied_lsn = cutoff_lsn - Postcondition: no WAL entry with LSN > cutoff_lsn exists on disk; idempotent (calling twice is safe)
- Note: WAL segments are 64 MB named
seg-{LSN_hex}.wal; no built-in LSN-to-offset index; sequential scan required
Entry type dispatch (5.1, 5.2):
WAL_JOB_SUBMIT,WAL_JOB_START,WAL_JOB_FINISH,WAL_QUEUE_CHANGE→persist_wal_sync_append()→ majority-commit path (blocking)WAL_HOST_STATE,WAL_CHECKPOINT_REF→persist_wal_async()→ best-effort (unchanged)
WAL trim guard: trim threshold changes from applied_lsn to committed_lsn to prevent trimming uncommitted entries.
Contracts: Service [x] / State [x]
Replication Layer (repl.h / repl.c)
Majority-Commit Broadcast
| Field | Detail |
|---|---|
| Intent | Implement repl_broadcast (was stub); add repl_wait_majority_ack; manage per-standby backpressure |
| Requirements | 5.1–5.8, 8.1–8.5 |
New standby_conn_t field: int pending_ack_count — backpressure counter; default max 64
repl_broadcast(cluster_t *c, uint8_t op, const void *payload, size_t len) contract:
- Sends payload to all connected standbys where
!in_catch_up && pending_ack_count <= max_pending_acks - Sends over persistent control channel fd (no new connection per entry)
- On send failure: remove standby from active set, trigger catch-up reconnect, log WARN
- Returns 0 when fanout initiated (not when ACKed)
repl_wait_majority_ack(cluster_t *c, uint32_t timeout_ms, uint8_t ack_op) contract:
- Blocks (cond_timedwait) until
acked_count + 1 >= (n_active + 1) / 2+1counts own write;n_activeexcludesin_catch_up==1standbys (5.4, Clarification 6)
ack_opparameter selectsOP_walAckvsOP_upgradeConfigAckto disambiguate concurrent commits- Returns 0 on quorum,
-ETIMEDOUTon timeout - Single-node shortcircuit:
n_active == 0→ quorum immediately from own write (5.8, 8.5)
Contracts: Service [x] / State [x]
Config Replication (config_repl.h / config_repl.c)
Config Bootstrap
| Field | Detail |
|---|---|
| Intent | Pre-election config tag discovery and deferred post-election fetch for unconfigured nodes |
| Requirements | 3.1–3.5 |
bootstrap_config_if_needed(cluster_t *c) contract:
- Returns immediately if
c->myconf->config_version_tag != 0 - Probes all peers via
xce_probe_config_tag(); three outcomes:- All peers
tag=0→ first-boot cluster, proceed to election (3.3) - Any peer has
tag > 0→ setc->ha.defer_config_fetch = true, return (3.2) - No peer reachable → retry with exponential backoff 500ms→10ms cap 10s; log WARN each attempt (3.4)
- All peers
- Never returns an error; blocks until resolution or process killed
xce_probe_config_tag(const char *host, uint16_t port, uint64_t *tag_out) contract:
- Single-RTT SSL request via
connect_ssl_raw()+ xce encode/decode - Request:
OP_probeConfigTagwithProbeConfigTagRequest{name}(requester name for peer logging) - Reply:
ProbeConfigTagReply{config_tag}(0 if peer has no config) - Peer answers in any FSM state — dispatch gate exempts
OP_probeConfigTagalongsideOP_requestVote - Returns 0 on success,
-ECONNREFUSED/-ETIMEDOUTon unreachable peer
Majority-Commit Config Push
| Field | Detail |
|---|---|
| Intent | Upgrade push_config_to_standbys() from fire-and-forget to majority-commit using shared repl infrastructure |
| Requirements | 8.1–8.5 |
push_config_to_standbys() extended contract:
- Write config blob locally (existing atomic write+rename — unchanged)
repl_broadcast(c, OP_upgradeConfig, blob, len)repl_wait_majority_ack(c, commit_timeout_ms, OP_upgradeConfigAck)- On quorum:
atomic_store(&c->myconf->committed_config_tag, tag)(8.1) - On timeout: return
-ETIMEDOUT; operator retries (8.3)
committed_config_tag: new field in cluster config state; piggybacked on CtrlHeartbeat (committed_config_tag @N :UInt64). Standbys store in ha.last_known_committed_config_tag. On promotion: serve only configs up to last_known_committed_config_tag (8.4).
Contracts: Service [x] / State [x]
Persistent Control Channel (ha.c / cluster.c)
Primary Discovery and Control Channel
| Field | Detail |
|---|---|
| Intent | Persistent TLS channel per standby; EPOLLRDHUP failover; full state sync on join |
| Requirements | 6.1–6.8 |
Three-tier primary discovery (post-election, standby path):
- Tier 1 (O(1) for majority): if
ha.voted_for_nameset, try direct connect to voted-for peer; onXCE_ERR_NOT_READYpeer still electing — backoff, retry Tier 1 - Tier 2 (redirect): probe remaining peers; a
CTRL_STANDBYpeer that knows the primary replies withXCE_ERR_STANDBY+redirect_host/port; follow redirect immediately - Tier 3 (blind scan): try remaining peers sequentially with 100ms→5s exponential backoff until primary found
Extended OP_ctrlHello schema (controller.capnp):
struct CtrlHelloRequest {
name @0 :Text;
key @1 :Data; # SHA-256(cluster_name + shared_secret); auth gate
applied_lsn @2 :UInt64;
config_tag @3 :UInt64;
}
struct CtrlHelloReply {
committed_lsn @0 :UInt64;
needs_snapshot @1 :Bool;
config_tag @2 :UInt64;
}
Failover detection — dual-trigger replacing timestamp polling (6.2, 6.3, 6.4):
- Fast:
EPOLLRDHUPonprimary_ctrl_fd→ immediately close, null fd, setCTRL_ELECTING - Fallback: heartbeat timeout vtask armed on each heartbeat receipt; fires after
election_timeout_ms + rand_jitterof silence; same action as EPOLLRDHUP path
XCE_ERR_STANDBY redirect extension (req 7.2, Tier 2):
Add to RequestReply (controller.capnp):
redirect_host @N :Text; # empty string when primary unknown
redirect_port @M :UInt16; # 0 when primary unknown
Standbys populate from ha.primary_host/port when known; clients without redirect fields fall back to normal retry.
State sync sequence on standby join (6.5, 6.6, 6.7):
Primary side (handle_ctrlhello()):
repl_add_standby()→repl_start_catchup()setsin_catch_up=1- Reply
CtrlHelloReply{committed_lsn, needs_snapshot, config_tag} - If
needs_snapshot:persist_checkpoint()→ sendOP_snapshotTransfer - Stream
OP_walCatchupEntry[applied_lsn+1 → current committed_lsn] - On
OP_walAckreaching committed_lsn →repl_complete_catchup()(in_catch_up=0) - If standby config_tag ≠ primary config_tag →
push_config_to_standbys()
Standby side:
- Store
primary_committed_lsn = reply.committed_lsn; updateha.last_known_committed_lsn persist_truncate_wal_above(primary_committed_lsn)— discard orphaned uncommitted entriesha.in_catch_up = 1— deny votes during sync (Clarification 6)- If
reply.needs_snapshot: receiveOP_snapshotTransfer, write, callload_snapshot() - Apply each
OP_walCatchupEntryviaapply_wal_entry(); sendOP_walAckafter each - On catch-up loop end from primary:
ha.in_catch_up = 0
Role flip on promotion (6.8): standby closes and nulls primary_ctrl_fd before returning from am_i_primary_quorum(); new primary accepts inbound OP_ctrlHello connections in a per-standby fd table.
Contracts: Service [x] / State [x]
Implementation Notes
- Risk:
apply_wal_entry()must exist before Change F lands; extract from startup WAL replay path as a prerequisite task - Risk:
persist_truncate_wal_above()requires sequential scan of partial WAL segment; validate with unit tests before integrating into standby join path - Integration:
repl_broadcast()skipsin_catch_up==1standbys during the streaming loop;repl_complete_catchup()fires only when standby'sacked_lsnequals currentcommitted_lsn
Client Retry (src/lib/request.c)
libxce Retry Wrapper
| Field | Detail |
|---|---|
| Intent | Transparent retry for transient controller errors; existing daemon code unchanged |
| Requirements | 7.1–7.6 |
xce_retry_config_t:
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;
xce_request_with_retry(endpoint_t *ep, xce_request_t *req, const xce_retry_config_t *cfg) contract:
- NULL
cfg→ uses defaults XCE_ERR_NOT_READY: backoff, retry same endpoint (7.1)XCE_ERR_STANDBYwithredirect_host/port: reconnect to redirect, retry (7.2)XCE_ERR_STANDBYwithout redirect: backoff, retry same endpoint (7.2 fallback)ECONNREFUSED/ETIMEDOUT: reconnect, retry (7.3)XCE_ERR_AUTH,XCE_ERR_PERM,XCE_ERR_INVALID,XCE_ERR_NOENT: surface immediately (7.4)- Exhausted
max_retries: surface last error to caller
Contracts: Service [x]
Implementation Notes
- Current
xce_request()is async send-only; the retry wrapper handles the synchronous request-reply cycle around it - Backoff jitter uses simple
rand_r()to avoid thundering-herd (7.6)
Wire Protocol (src/schema/controller.capnp)
New Opcodes and Message Types
Append after existing upgradeConfig @31:
| Opcode | @N | Purpose | Req |
|---|---|---|---|
requestVote |
@32 | Raft vote request/reply | 2 |
probeConfigTag |
@33 | Lightweight config tag probe | 3 |
walAck |
@34 | WAL majority-commit ACK | 5 |
upgradeConfigAck |
@35 | Config push majority-commit ACK | 8 |
snapshotTransfer |
@36 | Snapshot blob from primary to standby | 6 |
walCatchupEntry |
@37 | WAL entry during catch-up streaming | 6 |
Key struct definitions (to add to controller.capnp):
struct RequestVoteRequest {
term @0 :UInt64;
candidateId @1 :Text;
configTag @2 :UInt64;
lastLogLsn @3 :UInt64;
}
struct RequestVoteReply {
term @0 :UInt64;
voteGranted @1 :Bool;
peerConfigTag @2 :UInt64;
peerLastLogLsn @3 :UInt64;
}
struct ProbeConfigTagRequest { name @0 :Text; }
struct ProbeConfigTagReply { configTag @0 :UInt64; }
struct WalAckRequest { term @0 :UInt64; lsn @1 :UInt64; }
struct WalAckReply { term @0 :UInt64; lsn @1 :UInt64; success @2 :Bool; }
struct UpgradeConfigAck { term @0 :UInt64; ackedTag @1 :UInt64; success @2 :Bool; }
struct SnapshotTransfer { snapshotLsn @0 :UInt64; payload @1 :Data; }
struct WalCatchupEntry { lsn @0 :UInt64; entryType @1 :UInt8; payload @2 :Data; }
Also extend:
CtrlHeartbeat: addcommittedLsn @N :UInt64andcommittedConfigTag @M :UInt64for piggybacking (req 5.6, 8.4)RequestReply: addredirectHost @N :TextandredirectPort @M :UInt16for standby redirect (req 7.2)
Data Models
New Persistent State
ha-term.bin — fixed-size binary, atomic write+rename:
typedef struct {
uint64_t magic; // 0xDEADBEEFCAFEBABE
uint64_t term;
uint64_t voted_for_term;
char voted_for_name[XCE_MAXNAME_LEN];
uint32_t crc32; // CRC32 of all preceding fields
} ha_term_file_t;
- Read in
ha_init()at startup; missing file → term=0, no prior vote (first boot) - CRC mismatch → log WARN, initialize to term=0 (conservative: safe to vote again)
New In-Memory State
| Struct | Field | Type | Invariant |
|---|---|---|---|
persist_t |
committed_lsn |
_Atomic uint64_t |
committed_lsn <= applied_lsn always |
ha_state_t |
8 new fields | various | See Components section |
standby_conn_t |
pending_ack_count |
int |
<= max_pending_acks while in active set |
Consistency Guarantees
- WAL trim guard: threshold =
committed_lsnnotapplied_lsn; prevents trimming entries a promoted standby may need for divergence resolution committed_lsn <= applied_lsnasserted at promotion time; violated invariant is a programming errorha-term.binis written atomically before any vote reply is sent; a crash between write and reply means the term is persisted but the vote may not have been counted — conservative and safe
Error Handling
Election Errors
| Error | Condition | Response |
|---|---|---|
config_tag=0 and any peer has tag > 0 |
Hard gate A | Fatal exit -ENODATA; log ERROR with peer's tag value |
| Quorum not reached after 2 rounds | Normal election result | Enter CTRL_STANDBY; log WARN with vote tally |
| Fresher responding peer yield gate B | Winner yields to peer with higher tag or LSN | Enter CTRL_STANDBY; log INFO with peer tag/lsn seen |
ha-term.bin CRC mismatch at startup |
File corruption or incomplete write | Log WARN; initialize term=0; safe to proceed |
| Split vote after 2nd round | Split-vote safety net | Enter CTRL_STANDBY; log WARN |
WAL and Config Commit Errors
| Error | Condition | Response |
|---|---|---|
-ETIMEDOUT from repl_wait_majority_ack |
No quorum within commit_timeout_ms |
Return to caller; entry persisted locally but not committed; log WARN |
| Standby backpressure exceeded | pending_ack_count > max_pending_acks |
Remove from active set; trigger catch-up reconnect; log WARN |
Standby send failure in repl_broadcast |
TCP error on control channel fd | Same as backpressure — remove and trigger reconnect |
Startup Validation Errors
| Error | Condition | Response |
|---|---|---|
| Even peer count | n % 2 == 0 && n > 1 |
Fatal -EINVAL; operator-friendly stderr with correct format |
| Cert missing or unreadable | access(path, R_OK) != 0 |
Fatal -ENOENT; print full path |
--vcpus < 1 |
CLI argument | Fatal -EINVAL; print message |
vcpus key in config file |
Deprecation | Log WARN; do not fail startup |
Operational Constraint — WAL Stall Window
Between primary winning election and 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 correct CP behavior. Libxce retry (req 7) handles this transparently for workers and schedulers. Operators must be warned in deployment documentation.
Monitoring (Req 10)
All new paths emit structured xcelog.h log entries:
// Election completion (10.1)
INFO("election complete: role=%s term=%lu votes=%d/%d quorum=%d",
role_str, term, granted, n_peers, quorum);
// Bootstrap probe result (10.2)
INFO("config_bootstrap: outcome=defer best_peer_tag=%016llx", best_tag);
// WAL commit quorum (10.3)
INFO("wal_commit: lsn=%lu acked=%d/%d latency_ms=%d", lsn, acked, n_active, ms);
// Config push quorum (10.4)
WARN("config_push_timeout: tag=%016llx timeout_ms=%d", tag, commit_timeout_ms);
// FSM transition (10.5)
INFO("ha_role: %s -> %s term=%lu", from_str, to_str, term);
Testing Strategy
Unit Tests (src/controller/testsuite/)
| File | Verifies | Req |
|---|---|---|
test_ha_election.c |
Quorum met on 2/3 votes; voted_for enforcement across terms; freshness gate yields to fresher peer; split-vote triggers retry; single-node self-elects immediately | 2.1–2.12 |
test_ha_bootstrap.c |
All-zero first-boot proceeds; peer-has-config sets defer_config_fetch; no-peer-reachable retries with backoff; retry succeeds on 3rd attempt | 3.1–3.5 |
test_ha_durability.c |
ha_persist_term writes then ha_init restores term and voted_for; CRC corruption zeroes state safely; missing file zeroes state | 9.1–9.3 |
test_persist_truncate.c |
Truncation at segment boundary deletes whole segment; truncation mid-segment scans to correct offset; calling twice with same cutoff is idempotent | 5.6, 5.7 |
test_repl_majority.c |
Majority reached on 2/3 ACKs; timeout returns -ETIMEDOUT; single-node returns 0 immediately; in_catch_up standby excluded from quorum count | 5.1–5.8, 8.1–8.5 |
test_ha_startup.c |
Even peer count rejected; cert missing returns -ENOENT with path; --vcpus 0 rejected; vcpus in config logs deprecation warning but does not fail | 4.1–4.4 |
Unit Tests (src/lib/testsuite/)
| File | Verifies | Req |
|---|---|---|
test_libxce_retry.c |
NOT_READY triggers backoff and retry same host; STANDBY with redirect connects to redirected host; STANDBY without redirect falls back to backoff; AUTH error surfaces immediately; max_retries exhausted surfaces last error; 50 concurrent clients with backoff do not starve | 7.1–7.6 |
Integration Tests (multi-node, make test-integration)
| Scenario | Verifies | Req |
|---|---|---|
| Concurrent-startup: both nodes start within 3s | Only one node wins election; no false self-election | 1.1, 2.1 |
| Config-fresh node vs stale node election | Stale node yields; fresh node wins | 2.7 |
| Unconfigured node joins live cluster | defer_config_fetch set; deferred fetch succeeds; node enters standby with correct config | 3.2, 3.5 |
| Primary crash → standby promotion | Sub-second EPOLLRDHUP detection; promoted standby has all committed WAL entries | 5.6, 6.2 |
| WAL commit with all standbys down | Returns -ETIMEDOUT per entry; single-standby quorum resumes on reconnect | 5.5 |
| Config upgrade under network partition | Operator receives -ETIMEDOUT; retry after heal succeeds | 8.3 |
| Restart after participating in term N | Restored voted_for prevents double-vote | 9.1, 9.2 |
Performance Tests
| Test | Target | Req |
|---|---|---|
| Election round-trip latency (3-node LAN) | < 100ms p99 | 2.1 |
| WAL majority-commit overhead (3-node LAN) | < 5ms p99 per WAL_JOB_SUBMIT | 5.1 |
| Failover detection latency | < 1s from TCP disconnect to election start | 6.2 |
| 50 concurrent libxce clients post-failover | All succeed within 30s; no starvation | 7.6 |
Security Considerations
- Cert pre-flight (4.2): Controller refuses to start with missing or unreadable TLS cert/key. Prevents silent unencrypted operation.
- Cluster auth key in
OP_ctrlHello(6.1):key = SHA-256(cluster_name + shared_secret)gates standby join.cluster_keyis a new field read from the existing config file (not CLI, to avoid shell history exposure). Primary rejectsOP_ctrlHellowhere HMAC does not match. voted_forpersistence (9.1): Prevents double-vote within a term across restarts. A node that crashes after voting and restarts must not vote for a second candidate in the same term.- Term-based fencing: A primary that receives any reply with
term > my_termimmediately steps down (ha_do_demote()). Prevents a reconnected stale primary from continuing after partition heal. - No priority tiebreak: Raft freshness (config_tag + LSN) fully determines winner. The
ha.my_priorityfield is preserved inha_state_tfor backward compatibility but plays no role in election outcome.
Performance & Scalability
- Write latency: 1 LAN RTT + max(fsync_primary, fsync_standby) per majority-commit entry. At 0.1–0.5ms LAN RTT and 0.05–0.2ms NVMe: ~0.2–0.7ms commit latency. Applies only to
WAL_JOB_SUBMIT,WAL_JOB_START,WAL_JOB_FINISH,WAL_QUEUE_CHANGE. - Throughput ceiling: 1,000–20,000 job submits/sec before jobmgr locking dominates. Raft overhead is not the bottleneck at HPC-typical rates.
- 5-node vs 3-node write latency: negligible. 5-node waits for 2nd-fastest of 4 standbys vs 1st of 2; sub-millisecond difference on uniform LAN.
- Single-node shortcircuit: all majority-commit paths check
n_peers == 1first; self-only write immediately satisfies quorum. - Backpressure:
max_pending_acks = 64(configurable) prevents any single slow standby from stalling primary fanout. commit_timeout_ms: configurable, default 5000ms; tune per deployment network latency.