Captures all uncommitted work from ha-config-bootstrap, ha-heartbeat-liveness, and associated spec/tooling updates: - src: ha.c, ha.h, cluster.c, config_repl.c, myapp.c, raft.c — heartbeat liveness, RDHUP integration, committing phase fixes - tests: test_ha_role.c, test_config_repl.c, test_ha_rdhup_integration.c, test_p1_integration.c, test_p3_hardening.c, scheduler test - specs: election-iomgr-refactor, ha-heartbeat-liveness, ha-peer-connection-reuse, raft-library (new); ha-config-bootstrap and election-iomgr-refactor task/research updates - tooling: .wolf/ state, ha/data cluster fixtures, raft/raft.tla, src/build symlink, xce-build skill update
10 KiB
Requirements Document
Introduction
The XCE controller operates a 3-node HA cluster where one node is elected PRIMARY before serving jobs. The current election mechanism mixes blocking per-peer pthreads with the iomgr epoll event loop, causing SSL context confusion, capnp use-after-free, and race conditions.
This feature replaces the blocking pthread-based election with a fully async iomgr-driven implementation, and formalizes the complete HA protocol: election by config freshness, replication push after primary determined, and availability gate after majority ack.
Boundary Context
- In scope: Outgoing vote RPC transport; SSL context lifecycle for vote connections; election quorum; role-based vote grant/deny; config+state replication push to standbys after election; availability gate (majority ack before serving traffic).
- Out of scope: Incoming vote RPC handling (already correct in cluster.c); dynamic cluster membership changes (candidate list is static); config schema changes.
- Adjacent expectations: iomgr started and listener registered before election begins.
The FSM state machine and
ha_dispatch_gateremain the authority on opcode routing.
Requirements
1. No Blocking Threads for Outgoing Vote RPCs
Objective: As the cluster operator, I want the controller election to complete reliably without SSL errors or memory corruption, so that the cluster reaches PRIMARY state on every startup.
Acceptance Criteria
- The controller shall not create pthreads whose sole purpose is to send a vote request and block waiting for a reply during election.
- When the controller initiates an election round, the controller shall send
OP_requestVoteto each peer using the same I/O mechanism used for all other outgoing RPCs. - If a peer does not respond within the election probe timeout, the controller shall treat that peer as a denied vote without stalling the event loop.
2. Async Outgoing Vote Connection via iomgr
Objective: As the controller process, I want outgoing vote connections managed by the iomgr event loop, so that SSL handshake and data transfer do not block other I/O.
Acceptance Criteria
- When the controller sends a vote request to a peer, the controller shall establish the TCP connection and complete the TLS handshake without blocking the main event loop thread.
- The controller shall use a TLS client context (not a server context) for outgoing vote connections.
- When the TLS handshake with a peer completes, the controller shall send the
OP_requestVotepacket through the established connection. - If the TCP connection or TLS handshake to a peer fails, the controller shall count that peer as a denied vote and continue the election with the remaining peers.
3. Vote Reply Handling Through iomgr Dispatch
Objective: As the controller process, I want vote replies to arrive as normal iomgr packets, so that the capnp segment lifetime is managed consistently with all other RPCs.
Acceptance Criteria
- When a peer sends a vote reply, the controller shall decode it through the standard iomgr
packet-receive path rather than a raw blocking
read. - The controller shall not access capnp-decoded fields after the capnp segment for that packet has been freed.
- When a vote reply is received, the controller shall extract the peer's
term,vote_granted,peer_config_tag, andpeer_last_log_lsnand update the election tally. - If the peer's term in a vote reply is greater than the candidate's current term, the controller shall update its own term before recording the vote result.
4. Election Quorum, Ground-Truth, Timeout, and Retry
Objective: As the cluster operator, I want the election to converge to a single PRIMARY that holds the ground-truth cluster state, so that no data is lost and the cluster becomes available even if nodes start at different times.
Acceptance Criteria
- When vote replies from a majority of candidate nodes (≥ ⌈(n+1)/2⌉) grant the vote, the controller shall transition to CTRL_PRIMARY_COMMITTING (pre-availability state).
- The controller shall grant a vote to a candidate only if the candidate's
config_tagis greater than or equal to the voter's ownconfig_tag. This freshness gate guarantees that the elected primary holds the ground-truth configuration — no post-election config fetch is ever needed by the winner. - On cold start, only the node holding the highest
config_tagcan win election. All other nodes (config_tag=0 on first boot) will lose the freshness gate and cannot become primary. - When all peers have replied but no majority granted the vote, the controller shall retry after the next heartbeat timeout.
- When no vote replies arrive within the election probe timeout, the controller shall complete the round as a split vote and retry.
- While in CTRL_ELECTING state, if a heartbeat is received from a node that has become PRIMARY, the controller shall transition to CTRL_STANDBY and abandon the election.
- The controller shall retry elections indefinitely until it wins a quorum or detects an existing primary via heartbeat.
5. FSM State Consistency
Objective: As the controller process, I want the FSM state to accurately reflect the controller's role, so that the dispatch gate correctly accepts or rejects incoming RPCs.
Acceptance Criteria
- While in CTRL_ELECTING state, the controller shall accept
OP_requestVoteandOP_probeConfigTagand shall reply withXCE_ERR_NOT_READYto all other opcodes. - While in CTRL_PRIMARY_COMMITTING state, the controller shall accept replication-related
opcodes (config push ack, WAL ack) and shall reply with
XCE_ERR_NOT_READYto job dispatch and queue management opcodes. - When the controller transitions to CTRL_PRIMARY (fully available), it shall begin accepting job dispatch and queue management opcodes.
- When the controller transitions to CTRL_STANDBY, it shall begin accepting replication and heartbeat opcodes.
- The controller shall maintain a monotonically increasing term counter across election rounds and persist it to disk so the term survives a process restart.
6. Vote-Granting Rules Based on Voter Role
Objective: As the cluster operator, I want votes granted only when the voter has no live primary, so that a healthy primary is never displaced, and a crashed primary triggers a new election only after the heartbeat timeout expires.
Acceptance Criteria
- While in CTRL_PRIMARY state, the controller shall deny all incoming
OP_requestVoterequests regardless of the candidate's term or config_tag. - While in CTRL_STANDBY state with a live primary connection (heartbeat received within
heartbeat_timeout_ms), the controller shall deny all incomingOP_requestVoterequests. - When in CTRL_STANDBY state and the primary heartbeat has not been received within
heartbeat_timeout_ms, the controller shall transition to CTRL_ELECTING and begin granting votes to eligible candidates. - While in CTRL_INIT or CTRL_ELECTING state, the controller shall evaluate incoming
OP_requestVoterequests using the term, voted_for, and freshness gates (config_tag, last_log_lsn) without any role-based denial. - When a primary crashes (stops sending heartbeats), the surviving standbys shall detect
the loss within
heartbeat_timeout_ms, transition to CTRL_ELECTING, and elect a new primary from among themselves using the last committed state.
7. Post-Election Config and State Replication
Objective: As the cluster operator, I want the elected primary to immediately propagate its ground-truth configuration and cluster state to all standbys, so that standbys have up-to-date state and can become primary on failover without any post-election config fetch.
Acceptance Criteria
- When the controller wins election and enters CTRL_PRIMARY_COMMITTING, it shall push the
cluster configuration to all peer controllers that have a lower
config_tag. - When the controller wins election, it shall replicate its WAL (committed job/queue/worker state) to all standbys via the existing catch-up replication mechanism.
- When a new standby starts with
config_tag=0and loses the election, it shall receive the cluster configuration via the primary's replication push — it shall NOT attempt an independent config fetch before the primary pushes it. - The controller shall confirm config and state receipt from each standby via acknowledgement before counting that standby toward the majority ack threshold.
8. Primary Availability Gate
Objective: As the cluster operator, I want the primary to begin serving job traffic only after the majority of controllers have confirmed they hold the replicated state, so that failover can always promote a standby with current data.
Acceptance Criteria
- After winning election, the controller shall enter CTRL_PRIMARY_COMMITTING and shall not accept job dispatch or queue management requests until the availability gate opens.
- When a majority of peer controllers (≥ ⌈(n+1)/2⌉ − 1 peers, since primary counts itself) have acknowledged receipt of the current config and committed WAL state, the controller shall transition from CTRL_PRIMARY_COMMITTING to CTRL_PRIMARY.
- When CTRL_PRIMARY state is reached, the controller shall begin accepting all client opcodes including job dispatch, queue control, and resource management.
- If the majority ack threshold cannot be reached within a configurable timeout, the controller shall log a warning and open anyway (degraded mode) so the cluster does not deadlock when standbys are unavailable at startup.
9. No Regression in Existing Behaviors
Objective: As the cluster operator, I want all existing behaviors preserved after the refactor, so that nodes that previously worked correctly continue to do so.
Acceptance Criteria
- When a single-node cluster starts (candidate count = 1), the controller shall self-elect to CTRL_PRIMARY without contacting any peers.
- The elected primary shall never need to fetch config from another node — the vote freshness gate guarantees it already holds the highest config_tag.
- When the primary's control channel connection drops (RDHUP), the controller shall trigger a
new election. The winning standby shall promote via
ha_promote_to_primary(hooks only — its state is already current via continuous replication). - The controller shall prioritize election by node priority index: lower-index nodes start campaigning first, reducing split votes on simultaneous startup.