chore: baseline snapshot — prior-session work + ha-consistency-invariants spec family

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.
This commit is contained in:
Rongsong Shen 2026-06-11 13:21:50 +00:00
parent 084244941b
commit 06135954ff
88 changed files with 25611 additions and 5285 deletions

View file

@ -5,10 +5,9 @@ C daemon managing cluster-level job and queue operations. Acts as the central co
## Build
```bash
# From workspace root
nix develop
meson build && ninja -C build
/xce-build controller # debug + ASAN + clean build + runs tests
```
For full build system details, see `.kiro/steering/build-deployment.md`.
## Architecture
Core source files handle distinct domains:
@ -25,7 +24,9 @@ Links against `libxce` from `src/lib/`.
- Component-specific headers in `include/`
- Shared types defined in `src/include/xce/` (libxce)
## HA Protocol Verification
## HA Protocol
See `.kiro/steering/ha-async-io.md` for the full HA protocol reference: async I/O model (iomgr/vtaskmgr/delaymgr), connection ownership, Raft election, heartbeat model, two-tier replication consistency, dispatch gate, and TLA+ variable mapping. The TLA+ safety invariants and protocol verification checklist below are the authoritative controller-specific verification gates — keep them here.
### Raft TLA+ Formal Specification
@ -91,94 +92,9 @@ When modifying HA protocol code (election, replication, heartbeat), verify:
## HA Design Constraints
### Inbound vs. Outbound Connection Ownership (CRITICAL)
**Strict rule: inbound connections are for voting only. All persistent control channels are outbound from the primary.**
| Connection type | Direction | Purpose | Who closes |
|---|---|---|---|
| Vote RPC | Standby → Primary (outbound from standby) | `OPCode_requestVote` + reply only | Remote closes after reply |
| Standby control channel | Primary → Standby (outbound from primary) | ctrlhello, config push, WAL replication, heartbeat | `vote_ep_err_handler` / `standby_ep_err_handler_primary` on disconnect |
Rules:
- **Inbound vote connections** (accepted on the listener) are used **exclusively** for the vote exchange. After the reply is sent the primary does NOT reuse the connection for config/replication. The inbound ep is left open until the remote closes it.
- **Primary never uses an inbound connection to push state.** All state push (ctrlhello, config, WAL, heartbeat nop) goes over **outbound connections** stored in `ha->standby_endpoints`.
- During election: candidate opens outgoing vote connections (one per peer). After winning, `raft_cb_become_leader` promotes surviving vote connections to `standby_endpoints`.
- Late joiners: the primary's heartbeat task (`primary_heartbeat_fn`) scans the peers list and **opens a fresh outbound connection** to any peer absent from `standby_endpoints`. On connect, `on_standby_connected` sends ctrlhello and registers the ep.
- `CTRL_STANDBY` must not call `ha_find_and_connect_to_primary` or schedule `fetch_config_from_primary` after an election result — the primary will connect to it.
### Asynchronous, Non-Blocking I/O (CRITICAL)
All inter-controller communication uses iomgr (epoll-based) and vtaskmgr:
- `iomgr` dispatches inbound packets to vtask handlers — never block inside a vtask.
- `vtaskmgr` runs handlers on a thread pool; each handler must return quickly.
- Outgoing sends use `xce_request(ep, &req)` which enqueues to the endpoint's send buffer — non-blocking.
- Blocking I/O (e.g., `fetch_config_from_primary`, `ha_find_and_connect_to_primary`) is only permitted from `delaymgr` worker threads, **never** from iomgr/vtask context.
### Heartbeat Model
XCE uses a bidirectional heartbeat model aligned with Raft's AppendEntries:
- The **primary** sends periodic heartbeats (`OPCode_nop`) to all standbys via `ha_start_primary_heartbeat` (delaymgr task, interval=3s). During `CTRL_PRIMARY_COMMITTING`, this also serves as the replication driver — the primary evaluates standby state from heartbeat replies and pushes config/WAL/snapshot as needed.
- The **standby** replies to each heartbeat with its current `(applied_lsn, config_tag)` in `RequestReply.stateReport`. This gives the primary continuous visibility into standby convergence.
- The **standby** also arms a liveness watchdog (`ha_arm_heartbeat_timeout_vtask`) when entering `CTRL_STANDBY`. If no message arrives from the primary within `heartbeat_timeout_ms` (10s), it triggers re-election via `ha_on_primary_ctrl_fd_rdhup`.
- `raft.c` (the election library) is election-only. Heartbeat and replication logic belongs in the HA layer (`ha.c`, `config_repl.c`).
### Connection Ownership
- Promoted vote connections (`ha->standby_endpoints`): owned by `vote_ep_err_handler`; freed when the standby disconnects.
- Late-joiner inbound connections: owned by the cluster endpoint array (`c->endpoints`); the `ep_private_t` role identifies `HostRole_controller` for cleanup in `handle_endpoint_error`.
- `ha->primary_ctrl_fd`: set by `handle_ctrlhello` on the standby when the first ctrlhello arrives; used by `ha_on_primary_ctrl_fd_rdhup` to trigger re-election on disconnect.
## HA Cluster Deployment
Use the `/xce-deploy-ha` skill to deploy a local 3-node HA cluster for testing:
```
/xce-deploy-ha localhost:9991 localhost:9992 localhost:9993
```
**Prerequisites:**
- Build binary: `/xce-build controller`
- TLS certs and `cluster.bin` at `ha/data/` (already in repo)
**Quick deploy (from repo root):**
```bash
# Deploy + start all 3 nodes with raft trace logging
bash src/tools/deploy-ha.sh \
src/xce.build/controller/xce_controller \
ha/data \
/tmp/xce-deploy \
"localhost:9991,localhost:9992,localhost:9993"
VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-1 &
sleep 0.5
VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-2 &
sleep 0.5
VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-3 &
```
**Verify health (after ~15s):**
```bash
# Primary elected and config pushed
grep -i "CTRL_PRIMARY\|Config committed" /tmp/xce-deploy/logs/ctrl-1.log | grep -v DEBUG3
# Config files present on standbys
find /tmp/xce-deploy/controller/ctrl-2/conf /tmp/xce-deploy/controller/ctrl-3/conf -type f
```
**Clean restart** (keeps ctrl-1 config + all certs, wipes data/standby-conf/logs):
```bash
kill -9 $(pgrep xce_controller) 2>/dev/null || true
bash /tmp/xce-deploy/scripts/cleanup.sh
# Then restart nodes (no redeploy needed)
VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-1 &
sleep 0.5; VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-2 &
sleep 0.5; VERBOSE=-vvv /tmp/xce-deploy/scripts/start.sh ctrl-3 &
```
See `.claude/skills/xce-deploy-ha/SKILL.md` for full troubleshooting reference.
Covered in `.kiro/steering/ha-async-io.md` (connection ownership, async I/O, heartbeat, two-tier replication, dispatch gate) and `.kiro/steering/build-deployment.md` (deploy commands, cluster startup/restart/verification). The TLA+ safety invariants above remain the controller-specific verification reference.
## Key Files
- `src/controller/` — Controller source files
- `include/` — Controller-specific headers
- `src/tools/deploy-ha.sh` — HA cluster deploy script
## See Also
- Shared library: `src/lib/CLAUDE.md`

View file

@ -694,6 +694,17 @@ int handle_repl_config_push(cluster_t *c, endpoint_t *ep, xce_request_t *req)
return -EINVAL;
}
/* Req 1.3 (ha-heartbeat-liveness): any msg from primary resets standby
* liveness and steps down from CTRL_ELECTING (live primary discovered). */
{
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(
&c->ha.role, memory_order_acquire);
if (role == CTRL_STANDBY)
ha_on_heartbeat_received(&c->ha);
else if (role == CTRL_ELECTING)
ha_discover_primary(&c->ha, c);
}
repl_config_push_req_t *rcpr = req->body.replconfigpushrequestmsg;
if (!rcpr->raw_bytes || rcpr->raw_bytes_len == 0 || !rcpr->raw_sha256) {
@ -1404,6 +1415,9 @@ int (*xce_wal_stream_request_hook)(endpoint_t *ep, xce_request_t *req) = NULL;
#endif
/* send_wal_batch: enqueue a single OPCode_walCatchupEntry batch message.
* Entry payloads (raw_payload/raw_payload_len) travel as capnp Data via
* xce_wal_batch_req_raw (task 7.3) — the generated encode path cannot
* carry raw bytes (xce_data_t limitation, same as replConfigPush).
* Does not free any memory — caller manages entry lifetime.
* Returns 0 on success. */
static int send_wal_batch(endpoint_t *ep,
@ -1412,24 +1426,24 @@ static int send_wal_batch(endpoint_t *ep,
if (n_entries <= 0)
return 0;
/* Build the batch container entry */
wal_catchup_entry_t batch;
memset(&batch, 0, sizeof(batch));
batch.n_entries = n_entries;
batch.entries = entries;
xce_request_t req;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_walCatchupEntry;
req.body_which = Request_body_nomsg;
req.body.walcatchupentrymsg = &batch;
#ifdef XCE_TESTING
if (xce_wal_stream_request_hook != NULL)
if (xce_wal_stream_request_hook != NULL) {
/* Build the batch container the hook expects */
wal_catchup_entry_t batch;
memset(&batch, 0, sizeof(batch));
batch.n_entries = n_entries;
batch.entries = entries;
xce_request_t req;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_walCatchupEntry;
req.body_which = Request_body_walcatchupentrymsg;
req.body.walcatchupentrymsg = &batch;
return xce_wal_stream_request_hook(ep, &req);
}
#endif
if (ep)
xce_request(ep, &req);
xce_wal_batch_req_raw(ep, entries, n_entries);
return 0;
}
@ -1505,7 +1519,7 @@ int ha_stream_wal_to_standby(endpoint_t *ep, const char *wal_path,
if (batch_n > 0) {
send_wal_batch(ep, batch_entries, batch_n);
for (int bi = 0; bi < batch_n; bi++) {
free(batch_entries[bi]->payload);
free(batch_entries[bi]->raw_payload);
free(batch_entries[bi]);
}
batch_n = 0;
@ -1525,10 +1539,12 @@ int ha_stream_wal_to_standby(endpoint_t *ep, const char *wal_path,
if (pbuf) {
ssize_t pr = pread(fd, pbuf, hdr.payload_len,
(off_t)(off + sizeof(hdr)));
if (pr == (ssize_t)hdr.payload_len)
entry->payload = (xce_data_t *)pbuf;
else
if (pr == (ssize_t)hdr.payload_len) {
entry->raw_payload = pbuf;
entry->raw_payload_len = hdr.payload_len;
} else {
free(pbuf);
}
}
}
@ -1541,7 +1557,7 @@ int ha_stream_wal_to_standby(endpoint_t *ep, const char *wal_path,
batch_bytes >= WAL_STREAM_BATCH_BYTES) {
send_wal_batch(ep, batch_entries, batch_n);
for (int bi = 0; bi < batch_n; bi++) {
free(batch_entries[bi]->payload);
free(batch_entries[bi]->raw_payload);
free(batch_entries[bi]);
}
batch_n = 0;
@ -1557,7 +1573,7 @@ int ha_stream_wal_to_standby(endpoint_t *ep, const char *wal_path,
if (batch_n > 0) {
send_wal_batch(ep, batch_entries, batch_n);
for (int bi = 0; bi < batch_n; bi++) {
free(batch_entries[bi]->payload);
free(batch_entries[bi]->raw_payload);
free(batch_entries[bi]);
}
}
@ -1610,17 +1626,14 @@ int ha_send_snapshot_to_standby(endpoint_t *ep, const char *snap_dir,
uint64_t offset = 0;
uint32_t chunk_index = 0;
/* Chunk the snapshot file and enqueue each chunk via xce_request().
* Payload encoding uses NULL for now — the user-defined Data type in capnp
* (xce_data_t tagged union) requires a proper table dispatch for non-null
* payloads. This is a known limitation shared with wal_catchup_entry_t
* payload; both will be resolved when the capnp schema is updated to use
* the built-in capnp Data (List(UInt8)) type. */
/* Chunk the snapshot file and enqueue each chunk with its file bytes.
* Payload travels as capnp Data via xce_snapshot_chunk_req_raw (task 7.2)
* — the generated encode path cannot carry raw bytes (xce_data_t
* limitation, same as replConfigPush). */
while (offset < total_size) {
size_t chunk_len = (total_size - offset > SNAPSHOT_CHUNK_SIZE)
? SNAPSHOT_CHUNK_SIZE
: (size_t)(total_size - offset);
/* Consume the file bytes to advance offset — data not used yet */
if (fread(buf, 1, chunk_len, f) != chunk_len) {
rc = -EIO;
break;
@ -1630,26 +1643,26 @@ int ha_send_snapshot_to_standby(endpoint_t *ep, const char *snap_dir,
snapshot_transfer_t chunk;
memset(&chunk, 0, sizeof(chunk));
chunk.snapshot_lsn = checkpoint_lsn;
chunk.chunk_index = chunk_index;
chunk.chunk_offset = offset;
chunk.total_size = total_size;
chunk.is_last = is_last;
chunk.payload = NULL; /* encoding deferred */
xce_request_t req;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_snapshotTransfer;
req.body_which = Request_body_nomsg;
req.body.snapshottransfermsg = &chunk;
chunk.snapshot_lsn = checkpoint_lsn;
chunk.chunk_index = chunk_index;
chunk.chunk_offset = offset;
chunk.total_size = total_size;
chunk.is_last = is_last;
chunk.raw_payload = buf; /* borrowed for hook/encode only */
chunk.raw_payload_len = chunk_len;
#ifdef XCE_TESTING
if (xce_snapshot_chunk_hook)
if (xce_snapshot_chunk_hook) {
xce_request_t req;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_snapshotTransfer;
req.body_which = Request_body_snapshottransfermsg;
req.body.snapshottransfermsg = &chunk;
xce_snapshot_chunk_hook(ep, &req);
else
} else
#endif
if (ep)
xce_request(ep, &req);
xce_snapshot_chunk_req_raw(ep, &chunk, buf, chunk_len);
offset += chunk_len;
chunk_index++;

View file

@ -540,7 +540,10 @@ static void raft_cb_become_leader(void *ctx, uint64_t term)
ectx->votes[i].peer->hostname,
(uint32_t)ectx->votes[i].peer->port);
if (rc >= 0)
TRACE("HA: repl registered standby ep[%d] (slot %d)", i, rc);
INFO("HA: repl registered standby ep[%d] (slot %d, %s:%u)",
i, rc,
ectx->votes[i].peer->hostname,
ectx->votes[i].peer->port);
else
WARN("HA: repl_add_standby ep[%d] failed: %d", i, rc);
}
@ -1489,6 +1492,38 @@ int ha_dispatch_repl_action_for_standby(ha_state_t *ha,
* For CTRL_INIT / CTRL_ELECTING the allowlist is tighter: only
* OP_requestVote and OP_probeConfigTag pass through.
*/
/* ha_has_write_quorum — check whether the cluster has enough connected
* standbys to achieve majority for state-changing operations.
*
* Quorum = ceil(n_peers / 2) + 1 (self-vote included).
* Example: n_peers=3 → quorum=2 → need ≥1 connected standby.
*
* Called from ha_dispatch_gate for CTRL_PRIMARY — if the cluster loses
* majority (standby failures), write ops are rejected with -XCE_ERR_NOT_READY
* to preserve the majority-consistency invariant.
*
* Read-only queries (joblist, reslist, hostlist, etc.) remain available
* regardless of majority status — they don't mutate state. */
static bool ha_has_write_quorum(ha_state_t *ha)
{
if (!ha)
return false;
/* Single-node cluster: no standbys needed — self is the majority. */
if (ha->n_peers <= 1)
return true;
/* Count connected standbys. standby_endpoints is populated by
* raft_cb_become_leader (promoted vote eps) and on_standby_connected
* (late joiners); removed by error handlers. */
int connected = ha->standby_endpoints
? ha->standby_endpoints->count : 0;
/* Need at least n_peers/2 connected standbys to achieve quorum.
* Integer division: n_peers/2 for n_peers=3 → 1 standby minimum. */
return connected >= (int)(ha->n_peers / 2);
}
int ha_dispatch_gate(ha_state_t *ha, int opcode)
{
ctrl_role_t role =
@ -1496,6 +1531,31 @@ int ha_dispatch_gate(ha_state_t *ha, int opcode)
switch (role) {
case CTRL_PRIMARY:
/* State-changing operations require majority so committed entries
* are durable across failover. Read-only queries and replication
* ops are always allowed. */
if (!ha_has_write_quorum(ha)) {
switch (opcode) {
/* Replication ops — always allowed (they build majority) */
case OPCode_heartbeat:
case OPCode_replConfig:
case OPCode_replConfigPush:
case OPCode_upgradeConfigAck:
case OPCode_hello:
case OPCode_walAck:
case OPCode_replEntry:
case OPCode_replAck:
case OPCode_replCatchUpDone:
case OPCode_walCatchupEntry:
case OPCode_snapshotTransfer:
/* Read-only queries */
case OPCode_get:
case OPCode_probeConfigTag:
return 0;
default:
return -XCE_ERR_NOT_READY;
}
}
return 0;
case CTRL_INIT:
@ -2623,12 +2683,18 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
int peer_idx = vctx->peer_idx;
uint64_t committed_lsn = 0;
uint64_t committed_tag = 0;
uint64_t last_written = 0; /* latest WAL LSN — may be > committed */
#ifndef XCE_TESTING
if (c) {
committed_lsn = atomic_load_explicit(&c->persist.committed_lsn,
memory_order_acquire);
committed_tag = atomic_load_explicit(&ha->committed_config_tag,
memory_order_acquire);
/* current_lsn is the next LSN to write; last written = current - 1.
* Read without lock — monotonic, approximate value is sufficient
* for the replication decision. */
last_written = (c->persist.current_lsn > 0)
? c->persist.current_lsn - 1 : 0;
}
#else
/* In test mode, c may be NULL; caller sets ha fields directly */
@ -2658,8 +2724,15 @@ static int repl_ack_decode_and_dispatch(endpoint_t *ep, vote_ep_ctx_t *vctx)
oldest_wal_lsn = ha_compute_oldest_wal_lsn(c->persist.wal_path);
}
#endif
/* Replication target: use max(committed_lsn, last_written) so
* standbys can catch up even when committed_lsn is stuck at 0
* (no replicator-registered standbys to advance it). */
uint64_t target_lsn = committed_lsn;
if (last_written > target_lsn)
target_lsn = last_written;
ha_dispatch_repl_action_for_standby(ha, c, ep, peer_idx,
committed_tag, committed_lsn,
committed_tag, target_lsn,
oldest_wal_lsn);
} else if (rep_s.data_which == RequestReply_data_walackreplymsg) {
/* Task 4.1: WAL ACK reply from standby — extract lsn + success,
@ -3750,8 +3823,8 @@ static void on_standby_connected(endpoint_t *ep)
int rc = repl_add_standby(&c->persist.repl, ep,
peer->hostname, (uint32_t)peer->port);
if (rc >= 0)
TRACE("HA: repl registered late-joiner standby peer[%d] (slot %d)",
sctx->peer_idx, rc);
INFO("HA: repl registered late-joiner standby peer[%d] (slot %d, %s:%u)",
sctx->peer_idx, rc, peer->hostname, (uint32_t)peer->port);
else
WARN("HA: repl_add_standby late-joiner peer[%d] failed: %d",
sctx->peer_idx, rc);

View file

@ -525,16 +525,12 @@ int handle_jobsubmit(cluster_t* c, endpoint_t* ep, xce_request_t* request)
return -errno;
}
reply.status = 0;
reply.data_which = RequestReply_data_jobid;
reply.data.jobid = jobid;
xce_reply(ep, c->myhost, &reply);
/* Build job list for the AFTER_EVENT hook BEFORE replying to client.
* The AFTER_EVENT hook writes WAL and waits for majority replication —
* the client must not be acknowledged until the job is durable. */
xlist_init(&jlist, submit->array_size);
for (i = 0; i < submit->array_size; i++) {
/* Run hooks to tell scheduler */
lock(c->jobmgr, RTTI_LOCK_READ);
rc = key_find(c->jobmgr, (void*)(jobid + i), (void**)&j);
if (j != NULL) {
@ -554,13 +550,33 @@ int handle_jobsubmit(cluster_t* c, endpoint_t* ep, xce_request_t* request)
}
}
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, c, &jlist);
int hook_rc = xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB,
HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, c, &jlist);
xlist_iter(&jlist, (xlist_iter_fn_t)xlist_deref_job_item_, NULL);
xlist_fini(&jlist);
/* If persistence failed (replication timeout, I/O error), reject the
* submission so the client knows the job was not made durable. */
if (hook_rc != 0) {
WARN("handle_jobsubmit: persistence hook failed (rc=%d) — rejecting job",
hook_rc);
reply.status = hook_rc;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
return hook_rc;
}
/* Reply only AFTER WAL write + majority replication succeeded. */
reply.status = 0;
reply.data_which = RequestReply_data_jobid;
reply.data.jobid = jobid;
xce_reply(ep, c->myhost, &reply);
return 0;
}

View file

@ -1038,7 +1038,14 @@ int persist_wal_sync_append(persist_t* p, uint32_t type,
}
if (sync_rc_out)
*sync_rc_out = sync_err;
/* sync_err < 0 = degraded mode (local-only durability); continue operating. */
/* sync_err < 0 — replication failed.
* -ENODEV: no standbys registered (single-node or degraded).
* -ETIMEDOUT: standbys were registered but didn't ACK in time.
* Only -ENODEV is acceptable for forward progress; timeout means
* the cluster had standbys but couldn't reach quorum — the write
* must be rejected to preserve the majority-consistency invariant. */
if (sync_err == -ETIMEDOUT)
return -ETIMEDOUT;
return 0;
}

View file

@ -219,6 +219,14 @@ int repl_broadcast(replicator_t *r, const wal_entry_hdr_t *hdr,
mylock_lock(&r->lock);
if (r->nstandbys == 0) {
WARN("repl_broadcast: no standbys registered — WAL entry lsn=%lu type=%u"
" not replicated",
(unsigned long)hdr->lsn, hdr->type);
mylock_unlock(&r->lock);
return 0;
}
for (int i = 0; i < MAX_STANDBYS; i++) {
standby_conn_t *s = &r->standbys[i];

View file

@ -357,6 +357,8 @@ static void test_become_follower_closes_vote_endpoints(void)
ha_phase2_hook = NULL;
atomic_store_explicit(&ha.elect_ctx, NULL, memory_order_release);
free_elect_ctx(ctx);
free(ep0);
free(ep1);
ha_fini(&ha);
printf("test_become_follower_closes_vote_endpoints: PASS\n");
}
@ -494,6 +496,8 @@ static void test_follower_passive_no_outbound_connect(void)
ha_phase2_hook = NULL;
atomic_store_explicit(&ha.elect_ctx, NULL, memory_order_release);
free_elect_ctx(ctx);
free(ep0);
free(ep1);
ha_fini(&ha);
printf("test_follower_passive_no_outbound_connect: PASS\n");
}