Pre-existing uncommitted work: ha-repl-hardening payload data plane (7.x), heartbeat liveness fix (Req 1.3), connection-reuse ASAN fixes, doc cleanup. New: ha-consistency-invariants umbrella (requirements/design/research) and its four implementation sub-specs (specs generated via batch, cross-spec review applied), steering roadmap + product.md Raft+CP decision.
22 KiB
Design: HA Replication Hardening
Overview
Purpose: This feature hardens the XCE controller replication path so that the primary maintains majority-consistent state as a continuous invariant throughout its lifecycle — from post-election bootstrap through every state-changing operation until failover.
Users: Cluster operators who require CP semantics (consistency over availability) — no acknowledged state change is ever lost on primary failover.
Impact: Replaces the current timeout-only COMMITTING path with a unified heartbeat-driven replication protocol. The standby is fully passive — it only reports its current (config_tag, applied_lsn) in heartbeat replies. The primary makes all replication decisions (config push, snapshot, WAL streaming) from that single data point. The same code path handles fresh standby bootstrap, catch-up after disconnection, and continuous state verification.
Goals
- Unify replication into a single heartbeat-driven decision loop on the primary
- Standby remains fully passive — replies with state, receives what the primary sends
- Primary establishes majority consistency before serving first client request
- Primary continuously verifies majority consistency for every state-changing operation
- Lagging standbys receive snapshot + batch WAL catch-up to converge
handle_ctrlhelloreduces to connection setup only
Non-Goals
- Config push comparison (already implemented — pushed only when tags differ)
- WAL segment retention policy changes
- Dynamic cluster membership changes
- Client-visible API changes
- Election protocol changes
Majority Consistency Invariant
Any state change acknowledged to a client has been durably committed on a majority of controllers.
Enforced at three stages:
| Stage | Mechanism | When |
|---|---|---|
| Bootstrap | Heartbeat reply convergence check | After winning election, before serving client traffic |
| Ongoing WAL | repl_wait_majority_ack per entry (existing) |
On every job submit, state transition, queue change |
| Ongoing Config | push_config_to_standbys + majority ack (existing) |
On every config upgrade |
Boundary Commitments
This Spec Owns
- Heartbeat reply protocol: standby reports
(applied_lsn, config_tag)to primary - Unified replication decision loop: primary reads heartbeat state → decides config/snapshot/WAL
- Per-standby convergence tracking in
ha_state_t - WAL catch-up entry application on standby side
- Batch WAL entry encoding and transfer
- Snapshot creation and transfer
- COMMITTING→serving transition gated on majority convergence
Out of Boundary
- Election protocol (
election-iomgr-refactor) - WAL write path and
repl_wait_majority_ack(existing inha-config-bootstrap) - Config push path and config tag comparison (existing — reused by decision loop)
- Heartbeat send scheduling (existing
ha_start_primary_heartbeat) - Connection lifecycle (existing iomgr;
handle_ctrlhelloreduces to setup only)
Allowed Dependencies
primary_heartbeat_fn— existing heartbeat send loop; extended with reply processingapply_wal_entry()— existing WAL entry replaypersist_checkpoint()— existing snapshot creationrepl_wait_majority_ack()— existing quorum ack for ongoing operationspush_config_to_standbys()— existing config push (called from decision loop when tag differs)
Revalidation Triggers
- Changes to
RequestReplyunion schema incontroller.capnp - Changes to heartbeat interval or send mechanism
- Changes to WAL segment format or
apply_wal_entrysignature - Changes to
standby_conn_tor replicator tracking
Architecture
Unified Heartbeat-Driven Protocol
The standby is fully passive. The primary drives everything:
PRIMARY (heartbeat loop, fires every heartbeat_interval_ms)
For each standby in standby_endpoints:
1. Send OPCode_nop (or skip if recent traffic was sent)
2. Receive reply → (standby_tag, standby_lsn)
3. Decision:
if standby_tag < primary_committed_tag:
→ push_config_to_standby(standby)
return // re-evaluate next heartbeat
if standby_lsn < oldest_wal_lsn:
→ persist_checkpoint() → send OPCode_snapshotTransfer
return
if standby_lsn < primary_committed_lsn:
→ stream_wal_entries(from=standby_lsn+1, to=committed_lsn)
return // re-evaluate next heartbeat after catch-up
if standby_tag >= primary_tag AND standby_lsn >= committed_lsn:
→ mark converged ✓
4. After loop: if majority converged and role == COMMITTING:
→ transition to serving state
Key insight: The same decision tree handles all standby states — fresh bootstrap (tag=0, lsn=0), catch-up after disconnect (tag=current, lsn=stale), and converged (tag=current, lsn=current). No separate code paths needed.
handle_ctrlhello — Reduced to Connection Setup
Before this change, handle_ctrlhello (config_repl.c:1359) orchestrated replication:
- Registered standby in replication set
- Checked snapshot need
- Streamed WAL entries
- Pushed config
After this change, it only does connection bookkeeping:
- Set
primary_ctrl_fd = ep->fd(first ctrlhello from winner) - Call
ha_on_heartbeat_receivedto reset liveness watchdog - Arm
ha_arm_heartbeat_timeout_vtask
All replication decisions move to the heartbeat reply handler. The next heartbeat cycle will pick up the new standby and drive replication.
Architecture Diagram
sequenceDiagram
participant P as Primary
participant S as Standby
Note over P,S: --- Connection Setup ---
S->>P: OPCode_hello (applied_lsn=0, config_tag=0)
P-->>S: CtrlHelloReply (committed_lsn=10000, config_tag=T1)
P->>P: handle_ctrlhello: set primary_ctrl_fd, arm liveness
Note over P,S: --- First Heartbeat Cycle ---
P->>S: OPCode_nop (heartbeat)
S-->>P: reply {applied_lsn=0, config_tag=0}
P->>P: tag=0 < T1 → push config
P->>S: OPCode_replConfigPush {config blob, tag=T1}
Note over P,S: --- Second Heartbeat Cycle ---
P->>S: OPCode_nop
S-->>P: reply {applied_lsn=0, config_tag=T1}
P->>P: lsn=0 < 10000 → stream WAL [1..10000]
P->>S: OPCode_walCatchupEntry (batch: 1..256)
P->>S: OPCode_walCatchupEntry (batch: 257..512)
P->>S: ... (more batches)
Note over P,S: --- Third Heartbeat Cycle ---
P->>S: OPCode_nop
S-->>P: reply {applied_lsn=10000, config_tag=T1}
P->>P: tag=T1 ✓ AND lsn=10000 ✓ → converged!
P->>P: majority reached → CTRL_PRIMARY
Key Decisions
| Decision | Rationale |
|---|---|
| Primary drives all replication from heartbeat reply | Single decision point; no separate orchestrator code paths |
| Standby only reports state, never requests replication | Eliminates standby→primary request messages; standby is purely passive |
Config/snapshot/WAL decided from (tag, lsn) alone |
Two scalars encode all replication state needed |
| handle_ctrlhello reduces to connection setup | Clean separation: connection lifecycle vs replication logic |
| One action per heartbeat cycle per standby | Avoids flooding a standby with config + WAL simultaneously; natural pacing. stream_wal_entries() enqueues all batch messages via xce_request() (non-blocking) in a single invocation — "one action" means no further decisions for this standby until the next heartbeat reply confirms the new LSN |
File Structure Plan
Modified Files
| File | Change | Responsibility |
|---|---|---|
src/schema/controller.capnp |
Add stateReport :group {appliedLsn, configTag} to RequestReply.data |
Wire protocol |
src/controller/ha.h |
Add standby_reported_tag[N], standby_reported_lsn[N], standby_converged[N] |
Per-standby tracking |
src/controller/ha.c |
Add ha_process_standby_state() — the unified decision loop. Wire OPCode_walCatchupEntry handler. Process heartbeat replies. |
Core replication logic |
src/controller/cluster.c |
Extend OPCode_nop handler: populate stateReport with applied_lsn/config_tag |
Standby state reporting |
src/controller/config_repl.c |
Strip handle_ctrlhello to connection setup only. Add batch WAL streaming. Implement snapshot transfer. |
Replication actions |
src/controller/repl.c |
Minor: add helper to read convergence state from heartbeat tracking | Quorum helper |
No New Files
Components and Interfaces
Component: Unified Replication Decision Loop
| Field | Detail |
|---|---|
| Intent | On each heartbeat reply, read standby state, decide next replication action |
| Requirements | 1.1–1.4, 2.1–2.5, 6.1–6.5 |
| Location | ha.c — called from heartbeat reply processing |
Responsibilities & Constraints:
- Read
(standby_tag, standby_lsn)from heartbeat reply - Compare against
primary_committed_tagandprimary_committed_lsn - Decide: push config, send snapshot, stream WAL, or mark converged
- One action per heartbeat cycle per standby — config first, then WAL, then convergence
Contract:
Decision(standby_tag, standby_lsn) → Action
Action ∈ {
PUSH_CONFIG, // standby_tag < committed_tag
SEND_SNAPSHOT, // standby_lsn < oldest_wal_lsn
STREAM_WAL, // standby_lsn < committed_lsn
CONVERGED, // tag >= committed AND lsn >= committed
WAIT // action in progress, re-evaluate next cycle
}
Invariants:
- Config is pushed before WAL (tag must be current before LSN comparison is meaningful)
- Snapshot takes priority over WAL streaming (full state transfer needed)
- Convergence is terminal for this cycle — standby won't be re-evaluated until next heartbeat
Component: Heartbeat State Reporter (Standby Side)
| Field | Detail |
|---|---|
| Intent | Populate stateReport in every OPCode_nop reply |
| Requirements | 1.1, 1.3, 1.4 |
| Location | cluster.c OPCode_nop handler |
Cap'n Proto Schema Change:
# In RequestReply.data union:
stateReport :group {
appliedLsn @15 :UInt64; # standby's last applied WAL LSN
configTag @16 :UInt64; # standby's current config version tag
}
Implementation: In the OPCode_nop handler, replace data_which = nodata with data_which = stateReport, populate from ha->applied_lsn and myconf->config_version_tag.
Component: Per-Standby State Tracking
| Field | Detail |
|---|---|
| Intent | Store each standby's last reported state for convergence evaluation |
| Requirements | 2.1–2.5 |
| Location | ha.h ha_state_t |
Fields:
#define MAX_HA_CANDIDATES 8
uint64_t standby_reported_tag[MAX_HA_CANDIDATES];
uint64_t standby_reported_lsn[MAX_HA_CANDIDATES];
bool standby_converged[MAX_HA_CANDIDATES];
- Indexed by peer index (0..N-1), matching
--peersorder - Zero-initialized by
ha_init - Updated atomically from IO worker context on each heartbeat reply
- Read by convergence evaluator and committing timeout fn
Component: WAL Catch-Up Handler (Standby Side)
| Field | Detail |
|---|---|
| Intent | Apply WAL catch-up entries from primary |
| Requirements | 3.1–3.5 |
| Location | ha.c or cluster.c dispatch switch |
Wires the current stub (OPCode_walCatchupEntry returns 0) to apply_wal_entry(). For batch entries, applies each in order, advances applied_lsn after the full batch is durably written. Error entries are logged and skipped.
Durability: applied_lsn is not persisted to disk. On controller restart the standby always reports lsn=0; the primary drives full re-bootstrap (config push → WAL streaming → converged) via the unified decision loop. This is correct and expected — the decision loop handles lsn=0 identically to a fresh standby.
Component: Snapshot Transfer
| Field | Detail |
|---|---|
| Intent | Transfer full state snapshot when standby cannot catch up via WAL |
| Requirements | 4.1–4.5 |
| Location | config_repl.c, persist.c |
When Snapshot Is Needed
The unified decision loop checks two conditions on each heartbeat reply:
-
WAL gap (existing
repl_needs_snapshot):standby_lsn < oldest_wal_lsn— the standby's position predates the oldest retained WAL segment. WAL entries needed for catch-up no longer exist. -
Excessive WAL backlog (heuristic):
committed_lsn - standby_lsn > wal_streaming_threshold(configurable, default 10M entries). Even within the WAL window, streaming millions of entries would take too long — a snapshot is faster.
Design principle: Prefer WAL retention over snapshot. WAL entries are incremental (O(changes per operation)); a snapshot is O(total state size). Operators should configure wal_retention_hours large enough that snapshots are rare. Snapshot transfer is the fallback for long-disconnected standbys.
Transfer Strategy: Chunked + Resumable
A full snapshot may be hundreds of MB in a large cluster. Single-message transfer would block the control channel and be unrecoverable on failure.
Primary Standby
│ │
│ persist_checkpoint() → snap.bin │
│ OPCode_snapshotTransfer │
│ {chunk=0, offset=0, │
│ total=524288000, data=[64KB]} │
│ ─────────────────────────────────→ │ write to snap.part
│ │
│ OPCode_snapshotTransfer │
│ {chunk=1, offset=65536, ...} │
│ ─────────────────────────────────→ │ append to snap.part
│ ... (8000 chunks for 500MB) ... │
│ │
│ OPCode_snapshotTransfer │
│ {chunk=N, offset=..., │
│ data=[final], isLast=true} │
│ ─────────────────────────────────→ │ verify size, atomic rename
│ │ → load snapshot
│ │ → applied_lsn = checkpoint_lsn
│ ←── heartbeat reply ────────────── │ reports new lsn
Chunk format (per OPCode_snapshotTransfer message):
checkpoint_lsn :UInt64 — LSN this snapshot corresponds to
chunk_index :UInt32 — 0-based chunk number
chunk_offset :UInt64 — byte offset in the full snapshot
total_size :UInt64 — total snapshot size in bytes
is_last :Bool — true for the final chunk
data :Data — chunk payload (64KB max)
Failure handling: Mid-transfer reconnect always restarts snapshot from chunk 0 — the standby discards its partial snap.part file on reconnect and the primary re-creates the checkpoint from scratch. Resumption is intentionally not implemented: snapshots are rare (WAL retention is the primary path), and the extra protocol state (checkpoint_lsn, last_chunk_index) in stateReport would complicate the common heartbeat path for an edge case. The existing reconnect model (standby re-sends ctrlhello, primary drives next heartbeat cycle) naturally handles this without schema changes. Req 4.4's "retry by reconnecting" is satisfied by this restart-on-reconnect approach.
Completion: When all chunks are received, the standby verifies sum(chunk_sizes) == total_size, atomically renames snap.part → snap-{checkpoint_lsn}.bin, and loads it. The next heartbeat reply reports applied_lsn = checkpoint_lsn. applied_lsn is in-memory only — it is not persisted to disk. On a subsequent restart the standby reports lsn=0 and the primary re-runs the full bootstrap sequence.
Future: Block-Based Differential (Deferred)
For very large clusters where even chunked full transfer is slow, a block-based differential approach can be added later:
- Primary divides snapshot into fixed-size blocks (e.g., 4KB), computes SHA256 of each
- Standby computes SHA256 of its current state using same block alignment
- Primary sends only blocks whose SHA256 differs from the standby's
- Standby merges received blocks into its state
This is deferred — the chunked approach handles the common case, and WAL retention minimizes how often snapshots are needed.
Component: Batch WAL Sender
| Field | Detail |
|---|---|
| Intent | Transfer WAL entries in batches during catch-up |
| Requirements | 5.1–5.5 |
| Location | config_repl.c |
Accumulates entries up to batch limit (64KB/256 entries), sends as single message. Standby applies all entries in batch, advances applied_lsn after batch complete.
stream_wal_entries() enqueues all batch OPCode_walCatchupEntry messages via xce_request() (non-blocking send-buffer enqueue) in a single handler invocation — it never blocks the vtaskmgr thread. "One action per heartbeat cycle" means the primary makes no further replication decisions for this standby until the next heartbeat reply arrives confirming the standby's updated applied_lsn.
System Flows
Flow 0: Post-Election — Determine Safe Commit Point (Critical Safety)
Optimization: The winning candidate already has each voter's peerLastLogLsn and peerConfigTag from the RequestVoteReply messages (stored in ha_elect_ctx_t.votes[]). This eliminates the need to wait for the first heartbeat round — the safe commit point is computed immediately upon winning.
raft_cb_become_leader():
1. Collect (peerLastLogLsn, peerConfigTag) from votes[i] for all voters
2. safe_committed_lsn = max(lsn : count of voters with peerLastLogLsn >= lsn) + 1 >= majority)
3. safe_config_tag = max(tag : count of voters with peerConfigTag >= tag) + 1 >= majority)
4. persist_truncate_wal_above(safe_committed_lsn)
5. committed_lsn = safe_committed_lsn
Raft TLA+ alignment: Raft's BecomeLeader resets matchIndex[j] = 0 for all followers and determines commitIndex via subsequent AppendEntries round-trips. Our optimization uses the vote replies (which carry the Raft-equivalent of each voter's lastLogIndex) to compute the safe commit point with zero additional round-trips. This is safe because any committed entry was on a majority and at least one voter in the winning quorum has it.
Flow 1: Full Standby Bootstrap (config_tag=0, applied_lsn=0)
Heartbeat Cycle 1: standby reports (tag=0, lsn=0) → primary pushes config
Heartbeat Cycle 2: standby reports (tag=T, lsn=0) → primary streams WAL [1..N]
Heartbeat Cycle 3+: standby reports (tag=T, lsn=N) → converged
Each cycle is one heartbeat interval (3s). For a fresh cluster with empty WAL, bootstrap completes in 2 cycles (~6s). For a large cluster, WAL streaming spans multiple cycles.
Flow 2: Standby Rejoin After Brief Disconnect
Standby reports (tag=T, lsn=5000), primary has committed_lsn=10000. Standby is within WAL window → primary streams WAL [5001..10000]. Config already current → no config push. One or more heartbeat cycles depending on WAL volume.
Flow 3: Standby Rejoin After Long Disconnect (Snapshot)
Standby reports (tag=T, lsn=100), primary's oldest_wal_lsn=5000. Standby predates WAL window → primary creates checkpoint at lsn=10000, sends snapshot. Standby loads snapshot, reports lsn=10000 next heartbeat → converged.
Requirements Traceability
| Req | Design Element |
|---|---|
| 1.1–1.4 | cluster.c OPCode_nop handler: populate stateReport; capnp schema change |
| 2.1–2.5 | ha.c: unified decision loop + per-standby tracking arrays |
| 3.1–3.5 | ha.c/cluster.c: wire OPCode_walCatchupEntry → apply_wal_entry |
| 4.1–4.5 | config_repl.c: snapshot creation + OPCode_snapshotTransfer |
| 5.1–5.5 | config_repl.c: batch accumulation in WAL streaming loop |
| 6.1–6.5 | ha.c: heartbeat-driven majority check; committing timeout as 60s fallback |
| 7.1–7.5 | INFO/WARN log statements throughout new code paths |
Data Models
Cap'n Proto Schema Change
# In RequestReply.data union, add:
stateReport :group {
appliedLsn @15 :UInt64;
configTag @16 :UInt64;
}
In-Memory State
ha_state_t
├── standby_reported_tag[N] uint64_t per-standby last reported config tag
├── standby_reported_lsn[N] uint64_t per-standby last reported applied LSN
└── standby_converged[N] bool derived: tag>=committed && lsn>=committed
Error Handling
| Error | Handling |
|---|---|
| WAL entry apply fails | Log ERROR, skip entry, continue; standby reports partial progress next heartbeat |
| Snapshot transfer fails | Standby logs ERROR, reconnects; primary retries next time standby appears in heartbeat loop |
| Heartbeat reply lost | Primary re-sends next cycle (3s); no single-reply dependency |
| Majority lost (standbys disconnect) | Primary continues serving; logs WARN; repl_wait_majority_ack returns timeout for new writes |
| Committing timeout fires (60s) | Primary enters degraded mode; logs WARN with per-standby convergence state |
| Config push fails mid-cycle | Standby reports old tag next heartbeat → primary retries config push |
Testing Strategy
Unit Tests
- Decision loop:
(tag=0, lsn=0)→ PUSH_CONFIG;(tag=T, lsn=0)→ STREAM_WAL;(tag<T, lsn=N)→ PUSH_CONFIG;(tag=T, lsn=N)→ CONVERGED - Heartbeat reply populates
stateReportcorrectly - Majority threshold:
n_peers / 2(C integer division — primary + converged standbys form a cluster majority):- n=3: 3/2 = 1 converged standby needed → primary + 1 = 2 of 3 = majority
- n=5: 5/2 = 2 converged standbys needed → primary + 2 = 3 of 5 = majority
- n=1: 1/2 = 0 → immediate promotion (reconciled 2026-06-09)
- WAL catch-up: apply single entry, batch, error entry skipped
handle_ctrlhellono longer triggers replication — only connection setup
Integration Tests
- 3-node cluster: primary enters serving via majority convergence (not timeout)
- Fresh standby (tag=0, lsn=0): bootstrap through config → WAL → converged in heartbeat cycles
- Standby rejoin within WAL window: WAL streaming only, no config push, no snapshot
- Standby rejoin outside WAL window: snapshot transfer → WAL streaming → converged
- Config upgrade: majority ack before committed; standby reports updated tag next heartbeat
E2E Tests
- Full cluster startup via
/xce-deploy-ha: verify single PRIMARY via convergence, no degraded mode - Failover: kill primary, standby promotes, serves from committed state