Adds the Raft Apply-Ordering & Hook Decoupling roadmap phase: canonical pre-hooks -> raft commit -> apply -> notify ordering, with the raft protocol step lifted out of the notification hook fan-out (root of the bug-795/828/ 851/1060 divergence family). Decision: apply-after-commit via a single shared apply_committed_entry() seam reused by online apply and restart replay. - raft-drive-async: full spec docs (brief/requirements/research/design) committed alongside its already-tracked tasks.md; async-drive prerequisite for the apply-decoupling work, sequenced to start first (threatens failover liveness today). - raft-apply-decoupling: new spec (brief + facts-in-raft.md) covering the apply seam and the facts (WAL_MODE_RETAIN) apply-timing question (open decision D1). - job-array-single-wal-record: promoted from backlog to a tracked spec, sequenced after raft-apply-decoupling. - ha-consistency-invariants: add criterion 2.9 (Facts-Apply invariant), documenting the current on-receipt fact-reaction deviation this family will reconcile. - raft-commit-safety / raft-log-replication: reconcile spec.json phase to "implemented" (both were mislabeled tasks-generated despite full 18/18 and 24/24 task completion + raft_sim GO). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDWVNQLKxqTVHh42cH7rV3
36 KiB
Design Document — raft-drive-async
Overview
Purpose: Make the XCE controller's Raft protocol drive fully event-driven by removing every blocking operation from the two threads that carry cluster liveness — the iomgr epoll loop and the delaymgr timer — so that a slow/large transfer or an unreachable peer can no longer stall heartbeats and trigger a spurious election.
Users: Operators of multi-node controller clusters, who today see false failover under partition or large-snapshot conditions (the likely root of the bug-851 chaotic-failover family).
Impact: Changes how drive-thread work is scheduled, not what the protocol does. peer_ctrl_msg_handler's real work moves from inline-on-iomgr to the vtask pool; long blocking I/O (snapshot transfer, config-push, peer connect) moves to a new dedicated slow-lane executor; the delaymgr peer connect becomes bounded and offloaded. All existing consensus semantics, durability (persist-before-ACK), and the synchronous commit barrier are preserved unchanged.
Goals
- No blocking operation executes inline on the iomgr loop or the delaymgr timer (R1, R2, R5).
- Long blocking I/O runs on a bounded, admission-controlled lane isolated from the request vtask pool (R3).
- Per-connection processing order and consensus correctness preserved when peer control moves to the pool (R4).
- A network-partition (unreachable-peer) regression test proves heartbeat liveness and no spurious election (R7).
- No regression of existing HA features; steady-state heartbeat path unchanged (R6, R8).
Non-Goals
- Commit-barrier async (
repl_wait_majority_ack→ event-drivenresolve) — owned byraft-apply-decoupling(P1). - Apply-after-commit / hook decoupling / facts-through-raft.
- Asynchronous DNS resolution — deferred (HPC peers use IP/
/etc/hosts); onlyconnectis bounded here. - Snapshot content/format (entity-bearing snapshots) — this spec moves the transfer off the drive threads only.
- The MEDIUM vtask-pool blockers (config-ack wait, 30 s checkpoint poll, term fsync on vote) beyond what the lane and existing
COMMIT_WAIT_RESERVEDalready cover. - The admin
OP_upgradeConfigpush path (push_config_to_standbys→repl_wait_majority_ack, config_repl.c:1459) — it has a synchronous requester and stays a vtask-blocking operation underCOMMIT_WAIT_RESERVED; only the requester-less iomgr transport push is offloaded.
Boundary Commitments
This Spec Owns
- The scheduling discipline of drive-thread work: dispatching
peer_ctrl_msg_handlerto the vtask pool; the two-tier rule (fast protocol inline in the vtask, long I/O triggered to the slow lane). - A new slow-lane executor component: a bounded, admission-capped pool that runs long blocking I/O off the drive threads, with per-key in-flight deduplication.
- The bounded, offloaded peer-connect path (reorder of
socket_connect+ offloaded reconnect). - The network-partition regression scenario in the soak harness.
Out of Boundary
- The commit engine and
repl_wait_majority_ack(untouched; P1 owns its async conversion). - WAL/replication protocol content, snapshot format, config-upgrade materialization.
- Election/vote protocol logic (consumed as-is;
election-iomgr-refactoralready made vote fan-out async). - The durability guarantee and synchronous commit barrier (preserved, not modified).
Allowed Dependencies
ha-async-io.mdthread-role contract (authority).- Existing primitives reused, not modified in contract:
endpoint_block_tasks/endpoint_unblock_tasks(per-endpoint serialization), lock-guardediomgr_monitor/iomgr_unmonitor(thread-safe epoll registration),vtask_handle_msg/VTASK_IO dispatch,delay_task_ms(non-blocking trigger only),COMMIT_WAIT_RESERVED(admission pattern), thexce_probe_config_tagbounded-connect pattern, thefsync_worker/push_threadbackground-thread precedent. election-iomgr-refactor(completed upstream):delay_task_ms,on_connected, non-blocking vote fan-out.
Revalidation Triggers
- Any change to the slow-lane submit contract or its admission cap.
- Any change that makes a drive thread (iomgr/delaymgr) execute work synchronously again.
- Changes to per-connection ordering semantics of peer-control dispatch (affects consensus correctness assumers:
ha-term-safety,raft-apply-decoupling). - A change to
socket_connect's blocking/timeout behavior (affects all peer connection callers).
Architecture
Existing Architecture Analysis
Three drive threads exist (ha-async-io.md): iomgr (single epoll loop), vtaskmgr (N-worker pool, default 8), delaymgr (single timer). The corrected thread-role contract (2026-06-25) requires iomgr and delaymgr to never block inline. Current violations:
peer_ctrl_msg_handleris registered directly asep->msg_handler(ha.c:4265/4307/6349) → runs inline on iomgr; it triggers snapshot send (ha.c:2702 → config_repl.c:2446) and config-push (config_repl.c:1332) synchronously on iomgr.socket_connect(netio.c:289/302) blocks (setsO_NONBLOCKonly after connect, netio.c:716); reached fromprimary_heartbeat_fnon the delaymgr thread → an unreachable peer stalls all heartbeats.- The codebase's only deferral idiom,
delay_task_ms, runs the deferred callback on the delaymgr thread — a liveness thread — so it cannot host blocking work under the new contract.
Architecture Pattern & Boundary Map
Selected pattern: dispatch-and-offload — iomgr dispatches, the vtask pool does bounded protocol work, a new dedicated slow lane absorbs long blocking I/O; completions re-enter iomgr only through the existing lock-guarded registration call.
graph TB
subgraph drive[Drive threads - never block]
iomgr[iomgr epoll loop]
delaymgr[delaymgr timer]
end
subgraph pool[vtask pool - bounded work]
vtask[peer_ctrl vtask entry]
end
subgraph lane[slow-lane executor - dedicated bounded threads]
slow[blocking IO worker]
end
iomgr -->|dispatch VTASK_IO packet| vtask
vtask -->|fast protocol inline| vtask
vtask -->|trigger long IO| slow
delaymgr -->|trigger reconnect| slow
slow -->|blocking connect snapshot configpush| slow
slow -->|register connected fd via lock-guarded iomgr_monitor| iomgr
Architecture Integration:
- Domain boundaries: iomgr = I/O + dispatch; vtask = bounded protocol handling (serialized per endpoint by
endpoint_block_tasks); slow lane = long blocking I/O only; delaymgr = trigger-only. - Existing patterns preserved: VTASK_IO per-endpoint serialization, lock-guarded epoll registration,
delay_task_msas a non-blocking trigger,COMMIT_WAIT_RESERVEDadmission style. - New component rationale: the slow lane is the missing non-liveness execution lane — the one genuine gap; using dedicated threads (not the vtask pool) keeps long ops from starving request handling (R3 by construction).
- Dependency direction:
netio(lib) →slowlane→ha/config_repl(controller). Drive threads depend on the lane; the lane depends only on netio + libc.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Backend / Services | C (gnu17), pthreads | slow-lane executor; drive-thread dispatch changes | matches controller stack |
| Messaging / Events | existing iomgr/vtaskmgr/delaymgr + new slow lane | thread-role-compliant scheduling | no new external dep |
| Infrastructure / Runtime | epoll, poll(2), pthread mutex/cond/sem |
bounded connect, lane workers, admission | reuse existing patterns |
| Test | soak harness + firewall DROP (iptables/nft) | partition (unreachable-peer) scenario | capability-gated; see Testing |
File Structure Plan
New Files
src/controller/
├── slowlane.c # Bounded slow-lane executor: submit, worker loop, admission cap, per-key dedup
└── include/slowlane.h # slow-lane public API (submit, init, fini)
Modified Files
src/lib/vtaskmgr.c/src/include/vtaskmgr.h— LivenessAdmission: addvtask_class_t+ a classified submit (vtask_enq_classified); dispatcher reservesVTASK_LIVENESS_RESERVEDvcpus, fast-rejectingVTASK_REQ_BLOCKat the cap. Shared lib primitive → Revalidation Trigger.src/controller/commit.c— foldinflight_waits/COMMIT_WAIT_RESERVEDinto the pool reservation:commit_submitsubmits aVTASK_REQ_BLOCKinstead of self-counting.src/lib/netio.c—socket_connect: setO_NONBLOCKbeforeconnect, bound withpoll(timeout)(reusexce_probe_config_tagpattern). Add a connect-timeout parameter/config.src/controller/ha.c— register peer endpoints to dispatch peer-control through the vtask pool (reviseha.c:4265/4307/6349); convertpeer_ctrl_msg_handlerinto a vtask entry that does fast protocol work inline andslowlane_submits long ops; offload snapshot send (ha.c:2702) and reconnect (primary_heartbeat_fn/ha_connect_to_standby) to the slow lane with per-peer dedup.src/controller/config_repl.c— only the iomgr/promotion path: runpush_config_transport_onlyon the slow lane and replace thewait_all_with_timeoutdrive-thread join (reached frompeer_ctrl/promotion at ha.c:1172/1177/2672/2677/3973) with a slow-lane-internal wait + non-blocking return. In scope = the fire-and-forget transport push with NO waiting requester. The adminOP_upgradeConfigpath (push_config_to_standbys→repl_wait_majority_ack, config_repl.c:1459, "VTASK-CONTEXT ONLY") is OUT of scope — it has a synchronous admin requester awaiting the version/ACK reply and is a MEDIUM vtask-pool blocker bounded byCOMMIT_WAIT_RESERVED; it must NOT be offloaded (doing so would break the synchronous admin reply).src/controller/cluster.c(ormyapp.c) — init/fini the slow-lane executor in the controller lifecycle; pass connect-timeout config.src/include/config.h/ha.h— connect-timeout + slow-lane size/cap constants..claude/skills/xce-test-ha/run-ha-soak.sh(+xce_test) — partition (DROP) primitive and the partition scenario.
Each file keeps one responsibility;
slowlane.cis the only new logic module. peer-control logic stays inha.c(moved to a vtask entry, not relocated to a new file) to preserve reviewability against existing tests.
System Flows
Reconnect to an unreachable peer (R2)
sequenceDiagram
participant DM as delaymgr timer
participant SL as slow lane
participant IO as iomgr
DM->>DM: heartbeat tick - peer disconnected
DM->>SL: slowlane_submit reconnect peer if not in-flight
DM-->>DM: return immediately - other peers still heartbeat
SL->>SL: nonblocking connect + poll bounded timeout
alt connect succeeds
SL->>SL: prepare endpoint set msg_handler
SL->>IO: iomgr_monitor lock-guarded register fd
else timeout or refused
SL->>SL: clear in-flight - retry next tick
end
Key decisions: delaymgr only triggers (idempotent per-peer flag); the connect is bounded so it never approaches the heartbeat timeout; registration reuses the lock-guarded iomgr_monitor (no new primitive).
Snapshot send triggered by a catch-up request (R1)
sequenceDiagram
participant IO as iomgr
participant VT as peer_ctrl vtask
participant SL as slow lane
IO->>VT: dispatch repl-ack packet VTASK_IO
VT->>VT: fast - decode, update match_index
VT->>SL: slowlane_submit snapshot if not in-flight per standby
VT-->>IO: handler returns - endpoint unblocked, peer messages flow
SL->>SL: persist_checkpoint + fread chunk loop + send
Key decisions: the vtask must not hold ep->busy across the transfer (R4.3) — it triggers and returns; per-standby dedup prevents concurrent transfers (R1.5).
Requirements Traceability
| Requirement | Summary | Components | Flows |
|---|---|---|---|
| 1.1–1.4 | liveness during snapshot/config I/O | SlowLane, PeerCtrlDispatch | Snapshot flow |
| 1.5 | no concurrent snapshot per standby | SlowLane (dedup) | Snapshot flow |
| 2.1–2.2 | unreachable peer doesn't stall heartbeats | BoundedConnect, ReconnectOffload | Reconnect flow |
| 2.3 | bounded connect timeout | BoundedConnect | — |
| 2.4–2.5 | retry without self-terminate; dedup | ReconnectOffload, SlowLane | Reconnect flow |
| 2.6 | connect success registers without loss/reorder | ReconnectOffload (iomgr_monitor) | Reconnect flow |
| 3.1–3.4 | admission protection; long IO off pool/drive | SlowLane (cap), LivenessAdmission (pool reservation), PeerCtrlDispatch | — |
| 4.1–4.2 | per-connection processing order | PeerCtrlDispatch (endpoint_block_tasks) | — |
| 4.3 | don't hold block across long IO | PeerCtrlDispatch | Snapshot flow |
| 4.4 | iomgr-state mutation via lock-guarded iface | ReconnectOffload (iomgr_monitor) | Reconnect flow |
| 4.5–4.6 | preserve vote/ack/term + persist-before-vote | PeerCtrlDispatch, LivenessAdmission (ack-progress reservation) | — |
| 5.1–5.4 | thread-role contract compliance | all | — |
| 6.1–6.7 | no regression of existing HA features | all (regression suite) | — |
| 7.1–7.4 | partition verification | PartitionTest | — |
| 8.1–8.2 | heartbeat fast-path unchanged | PeerCtrlDispatch | — |
Components and Interfaces
| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| SlowLane | controller/infra | bounded executor for long blocking I/O | 1, 3 | pthreads, sem (P0) | Service, Batch |
| LivenessAdmission | lib/vtaskmgr | pool-level task-class reservation: liveness work always has a worker | 3, 4.5 | vtaskmgr dispatcher (P0) | Service |
| PeerCtrlDispatch | controller/ha | move peer-control to vtask; two-tier | 1,4,5,8 | vtaskmgr, LivenessAdmission, endpoint_block_tasks (P0) | Service |
| BoundedConnect | lib/netio | non-blocking + poll-bounded connect | 2 | poll (P0) | Service |
| ReconnectOffload | controller/ha | delaymgr triggers offloaded reconnect | 2 | SlowLane, iomgr_monitor (P0) | Service |
| PartitionTest | test | unreachable-peer regression | 7 | soak harness (P1) | Batch |
Controller / Infrastructure
SlowLane
| Field | Detail |
|---|---|
| Intent | Bounded, admission-capped pool that runs long blocking I/O off the drive threads, with per-key in-flight dedup |
| Requirements | 1.1, 1.2, 1.3, 1.5, 3.1, 3.2, 3.3, 3.4 |
Responsibilities & Constraints
- Owns a fixed set of dedicated worker threads (sized to peer count; small constant) — isolated from the vtask request pool, so long ops cannot starve request handling.
slowlane_submitis non-blocking (enqueue + return); callers on drive threads never wait.- Enforces an admission cap on concurrent in-flight tasks; excess is queued, never run on a drive thread (R3.3/3.4).
- Per-key dedup: a submit with an in-flight key is a no-op (R1.5 snapshot-per-standby; R2.5 reconnect-per-peer).
- Does not own the work content (connect, snapshot, config-push bodies stay in their modules); it owns scheduling/isolation.
Dependencies
- Inbound: PeerCtrlDispatch — submits long ops (P0); ReconnectOffload — submits reconnect (P0).
- Outbound:
iomgr_monitor(via the submitted task) — register connected fd (P0). - External: pthreads (threads, mutex, cond, sem) (P0).
Contracts: Service [x] / Batch [x]
Service Interface
typedef void (*slowlane_fn_t)(void *arg);
int slowlane_init(slowlane_t *sl, int n_workers, int max_inflight);
/* submit returns 0 (enqueued), -EEXIST (key in flight, deduped), -EAGAIN (cap full, queued/deferred);
never blocks the caller. key==0 disables dedup. */
int slowlane_submit(slowlane_t *sl, uint64_t key, slowlane_fn_t fn, void *arg);
void slowlane_fini(slowlane_t *sl); /* drain + join on shutdown only */
- Preconditions:
fnperforms the blocking work and is responsible for clearing any caller-side in-flight state on completion. - Postconditions:
fnruns exactly once on a lane worker; never on iomgr/delaymgr. - Invariants: concurrent running tasks ≤
max_inflight; one in-flight task per non-zero key.
Snapshot/checkpoint concurrency invariant (Phase 3, R1.1/1.3/1.5 — MUST hold before snapshot offload lands)
Moving persist_checkpoint (which runs trim_wal_segments — deletes WAL segments) + the snapshot fread loop onto a lane worker runs them concurrently with live WAL appends, the fsync_worker flusher, and the WAL-catchup stream. The lane snapshot path must obey:
- Frozen boundary: the snapshot is taken at a fixed
(term, lsn)checkpoint boundary captured at submit time; thefread/send loop reads only data at or below that boundary, never live tail bytes. - Trim safety (MECHANISM, not just goal — see bug-1147):
trim_wal_segments(persist.c:1374) TODAY bounds deletion ONLY byactive_seg_lsn(the open segment) — it consults no standbyacked_lsn, nocommitted_lsn, no snapshot boundary, and there is no working snapshot backfill (write_snapshotemits only a 16-byte P0 sentinel, not materialized state). So a standby lagging by more than ~one open segment already hits a permanent committed-prefix gap (the bug-795/828 family). Moving checkpoint/trim onto a concurrent lane worker makes this latent race easier to hit. Therefore this spec MUST add the cursor-bound guard as a stopgap:trim_wal_segmentscomputesfloor = min(all standby acked_lsn, all in-flight snapshot boundaries)underp->lockand refuses to delete any segment whose range reachesflooror above — never holdingp->lockacross thefread/send I/O. This is strategy-A retention discipline that should exist regardless of this spec; the full fix for empty/far-behind join (cursor=0) requires entity-bearing snapshots (separate spec) and is explicitly NOT solved here. The design must not assume "a snapshot will backfill the gap" — that path is not built. - Single in-flight checkpoint: at most one checkpoint/trim in flight (lane dedup key = checkpoint), and at most one snapshot per standby (dedup key = standby id, R1.5).
- No correctness change: the lane only relocates execution; the checkpoint/trim/snapshot content is byte-identical to the current inline path.
Implementation Notes
- Integration: init in controller lifecycle (cluster.c/myapp.c); size from peer count.
- Validation: assert submit never called from a context expecting completion; metric on queue depth/age (fail-loud on backlog); a test asserting concurrent append + checkpoint/trim produces no segment-below-boundary deletion and a consistent snapshot.
- Risks: a lane worker blocked indefinitely (e.g., snapshot to a half-open peer) — bound every in-lane blocking op: the connect uses the bounded
socket_connect; the snapshot send must use a bounded socket write (write timeout, mirroring the bounded-connect approach) so a standby going unreachable mid-transfer cannot pin a lane worker. Otherwise stuck transfers exhaust the bounded lane and delay reconnects (which share it). Phase-3 task constraint; size/segment the lane so reconnect and transfer do not starve each other.
LivenessAdmission (lib/vtaskmgr)
| Field | Detail |
|---|---|
| Intent | Make "keep K workers free for liveness" a property of the vtask pool, not of the commit engine — so it covers every blocking op the pool runs once peer-control moves onto it, not just commit-waits |
| Requirements | 3.1, 3.2, 3.3, 4.5 |
Why this exists (the gap it closes): Today vtaskmgr (include/vtaskmgr.h) has a single FIFO taskq, N vcpu workers, and no priority/reservation; a blocking task holds its vcpu in VCPU_BUSY for the whole block (vtaskmgr.c:248-275). The only admission anywhere is commit.c's inflight_waits counter (COMMIT_WAIT_RESERVED=2, commit.c:200) — external to vtaskmgr and counting only commit-waits. Once peer_ctrl moves onto the pool (PeerCtrlDispatch), the pool also runs the vote-grant term fsync (R4.6) and config-ack waits — blocking ops invisible to the commit counter. So "≥2 workers free of commit-waits" no longer means "≥2 workers free for acks": a burst of vote-fsyncs can occupy the headroom and starve the ack that wakes the commit-waiters. The reservation must therefore become pool-level.
Responsibilities & Constraints
- Classify every vtask at enqueue into
VTASK_LIVENESS(never parks — WAL-ack decode, heartbeat send, vote receipt/decode;commit_on_ackis documented never-block,commit.c:16),VTASK_LIVE_BLOCK(bounded mandatory park — vote-grant term fsync, R4.6), orVTASK_REQ_BLOCK(may park seconds — commit-wait, config-ack wait). Class is knowable from the message type at the dispatch shim. - The dispatcher reserves
VTASK_LIVENESS_RESERVEDvcpus: aVTASK_REQ_BLOCKtask is not admitted whencount(vcpus running *_BLOCK) >= n_vcpus - VTASK_LIVENESS_RESERVED— it fast-rejects with-XCE_ERR_NOT_READY(preserving today's client backpressure, Req 7.1/7.2), it does not silently queue or park a worker. VTASK_LIVENESStasks are never admission-gated → always find a reserved vcpu → acks always advance commit → parked*_BLOCKtasks always wake. Deadlock closed by construction.VTASK_LIVE_BLOCK(vote fsync) runs in the reserved headroom and is bounded, so a vote response never queues behind a seconds-long commit-wait (removes a second spurious-election vector).commit.c'sinflight_waits/COMMIT_WAIT_RESERVEDis folded into this —commit_submitbecomes "submit aVTASK_REQ_BLOCK"; the pool owns the single reservation (commit_engine_initno longer needsn_vcpus,cluster.c:1332).
Dependencies: Inbound — PeerCtrlDispatch, commit (P0). Outbound — none (lib-internal). External — pthreads.
Contracts: Service [x]
typedef enum { VTASK_LIVENESS=0, VTASK_LIVE_BLOCK, VTASK_REQ_BLOCK } vtask_class_t;
/* submit a classified task; VTASK_REQ_BLOCK returns -XCE_ERR_NOT_READY when the
reservation would be violated (retriable, never parks a worker). */
int vtask_enq_classified(vtaskmgr_t*, vtask_t*, vtask_class_t);
- Invariant:
count(vcpus in *_BLOCK) <= n_vcpus - VTASK_LIVENESS_RESERVED; ≥VTASK_LIVENESS_RESERVEDvcpus always available toVTASK_LIVENESS.
Implementation Notes
- Sizing (peer-scaled):
R = VTASK_LIVENESS_RESERVED = clamp(n_peers − 1, 1, n_vcpus − 1); commit/REQ_BLOCKcap =n_vcpus − R. Rationale: per-endpoint serialization (endpoint_block_tasks) caps in-flight tasks at one per peer connection, so worst-case concurrent liveness work is N−1 (one ack/vote per other peer) — reserving N−1 means no peer's liveness message ever queues behind a busy reserved worker. Correctness needs only the floor of 1 (one guaranteed-free worker + liveness-never-gated + sub-ms cycling = deadlock-free regardless of peer count); the peer-scaled middle is a latency optimization. Ceilingn_vcpus − 1keeps ≥1REQ_BLOCKslot so commits can still run (cap never 0). Bothn_peersandn_vcpusare final at init (cluster.c:1332already passes both). Config-overridable. On a degenerate small-core box (n_vcpus < n_peers) R clamps ton_vcpus − 1(full per-peer reservation impossible) — still correct via fast cycling; emit a loud WARN at init so the under-provisioning is operator-visible. Validate the chosenRat supported sizes (R8). - Validation: misclassification is the one failure mode — add a debug assert/loud-WARN if a
VTASK_LIVENESStask parks beyond a threshold; task 3.4 (acks advance commit under saturation) is the regression oracle. - Risk: a future blocking path tagged
VTASK_LIVENESSsilently breaks the guarantee — review discipline + the assert + the saturation test. - Revalidation Trigger: any change to the class set, the reservation size, or the dispatch admission rule (consensus correctness + the slow-lane admission contract depend on it).
PeerCtrlDispatch
| Field | Detail |
|---|---|
| Intent | Dispatch peer-control message handling to the vtask pool; fast work inline, long work to the lane |
| Requirements | 1.4, 4.1, 4.2, 4.3, 4.5, 4.6, 5.1, 5.4, 8.1, 8.2 |
Responsibilities & Constraints
-
Peer endpoints dispatch through the existing VTASK_IO path (the message is packet-triggered → reuses
vtask_handle_msg), inheritingendpoint_block_tasksper-endpoint serialization (R4.1/4.2). -
The vtask entry performs only bounded protocol work inline (decode, match_index update, commit eval, send next heartbeat) and
slowlane_submits any long op; it must not holdep->busyacross long I/O (R4.3). -
Preserves existing vote/ack/term semantics and persist-before-vote ordering (R4.5/4.6) — the logic is unchanged, only its execution context moves.
-
Commit-advance preservation (R4.5): WAL-ack handling — which wakes the commit barrier (
repl_wait_majority_ack/commit_set_raft_commit_index) — moves off iomgr to the vtask pool. The design preserves that a quorum of acks still advancescommitted_lsnpromptly: ack handling is bounded fast work (never offloaded to the slow lane), per-endpoint serialization does not delay cross-endpoint acks (each peer's acks process independently), and the commit-advance path itself is untouched. This is the only interaction with P1 (raft-apply-decoupling, which owns the barrier's async conversion); this spec must not change ack→commit-advance semantics, only the thread the ack is decoded on. -
Deadlock-freedom argument (R4.5/R3.1 — REQUIRED reasoning, reverses a documented invariant): Today
commit_on_ackis explicitly "called from the iomgr read path and never blocks" (commit.c:16,repl.c:220) — the ack that advancescommitted_lsnand the jobsub vtasks blocked incommit_submit(COMMIT_MODE_WAIT)(the synchronous barrier this spec PRESERVES) run on different threads. Moving peer-control to the vtask pool co-locates the wakers (acks) and the waiters (blocked commit-submits) on one pool — exactly the configuration the iomgr placement was chosen to avoid. Safety rests on a worker reservation — but the existingcommit.ccap is insufficient as-is:COMMIT_WAIT_RESERVED(commit.c:200) counts only commit-waits, while movingpeer_ctrlonto the pool also puts the vote-grant term fsync (R4.6,VTASK_LIVE_BLOCK) and config-ack waits on it — blocking ops the commit counter can't see. "≥2 workers free of commit-waits" therefore does not mean "≥2 workers free for acks": a burst of vote-fsyncs can occupy the headroom and starve the ack that wakes the commit-waiters. Closing the deadlock requires the LivenessAdmission component (above) — the reservation must become a pool-level property covering every blocking class, withVTASK_LIVENESS(acks) never admission-gated. Required invariants this spec must hold (and test): (1) WAL-ack/commit-advance decode is taggedVTASK_LIVENESS, never routed to the slow lane, never gated — admissible independently of any blocking count; (2) the reserved headroom is sized for all concurrent liveness work (N-peer acks + vote responses + heartbeat sends), so the reservation — not luck — guarantees an ack always finds a worker. This co-location is transient by design:raft-apply-decouplingP1 convertsrepl_wait_majority_ackto event-driven (no parked thread per commit), removing theVTASK_REQ_BLOCKwaiters from the pool entirely — but until P1 lands, correctness depends on LivenessAdmission holding. Dependency: PeerCtrlDispatch (3.1) requires LivenessAdmission to land first (Phase 1). Gate: task 3.4 (quorum-of-acks advancescommitted_lsnwith the pool saturated by*_BLOCKtasks) is the admission gate for 3.1 — 3.1 is not "done" until 3.4 is green.
Dependencies
- Inbound: iomgr — dispatches the packet (P0).
- Outbound: vtaskmgr (P0); SlowLane — long ops (P0); existing commit/repl/persist logic (P0).
Contracts: Service [x]
Implementation Notes
- Integration: change peer endpoint
msg_handlerregistration (ha.c:4265/4307/6349) to the vtask-dispatching shim; convertpeer_ctrl_msg_handlerbody into the vtask entry. - Validation: existing election/heartbeat/repl tests must pass unchanged (R6); assert no long op runs inline in the entry.
- Risks: a heavy op accidentally left inline → per-peer head-of-line blocking (guard by review + a test asserting the handler returns promptly).
BoundedConnect (lib/netio)
| Field | Detail |
|---|---|
| Intent | Make socket_connect non-blocking + poll-bounded |
| Requirements | 2.3 |
Responsibilities & Constraints: set O_NONBLOCK before connect; on EINPROGRESS, poll up to a configured timeout; return success/timeout/error. Reuses the xce_probe_config_tag pattern.
Contracts: Service [x]
/* connect_timeout_ms: bound the attempt; 0 = use default (config). */
int socket_connect(const char *host, int port, int connect_timeout_ms);
Implementation Notes: callers updated to pass the timeout; default in config.h. This is a behavior change to a shared helper → Revalidation Trigger for all connect callers.
ReconnectOffload (controller/ha)
| Field | Detail |
|---|---|
| Intent | delaymgr triggers an offloaded, deduped, bounded reconnect |
| Requirements | 2.1, 2.2, 2.4, 2.5, 2.6 |
Responsibilities & Constraints: primary_heartbeat_fn (delaymgr) detects a disconnected peer and slowlane_submits the reconnect (per-peer key for dedup); the lane worker does the bounded connect, prepares the endpoint, registers it via lock-guarded iomgr_monitor; on failure clears the in-flight flag and lets the next tick retry (never self-terminates).
Contracts: Service [x]
Implementation Notes: refcount the endpoint across the in-flight window; resolve the inbound-vs-outbound connection race per the existing connection-ownership rules (ha-async-io.md).
Test
PartitionTest
| Field | Detail |
|---|---|
| Intent | Make one peer unreachable (blackhole), assert liveness + no spurious election + heal |
| Requirements | 7.1, 7.2, 7.3, 7.4 |
Contracts: Batch [x]
Implementation Notes: the partition primitive must render one peer unreachable (traffic dropped, connect hangs), distinct from kill -9. Two mechanisms, primary = in-process so R7 actually runs in this project's environment (the sibling specs ha-term-safety 5.2/5.4 are already perpetually ENV-BLOCKED on the unprivileged/overlayfs sandbox; a firewall-only R7 would be a permanent skip and the spec's headline guarantee would never be exercised):
- Primary (portable, no privilege): in-process socket-drop hook in
xce_test— a test-only seam (XCE_TESTING) that makes one node silently drop/blackhole inbound+outbound peer traffic at the endpoint layer (e.g. stop reading/writing the peer fd, or a drop flag in the I/O path), simulating "unreachable" without any kernel/firewall change. Runs anywhere the unit/soak suite runs. - Secondary (infra-level, optional): firewall
iptables/nftDROP on the node's port for real-network fidelity; capability-gated with a loud skip when unprivileged. Complements, does not replace, the in-process path.
Assert (both): heartbeats to survivors continue within the interval, no new-leader election while the leader holds majority, peer rejoins on heal, zero committed-job loss.
Error Handling
Error Strategy
- Connect timeout/refused → bounded
pollreturns; lane clears in-flight; delaymgr retries next tick; loud WARN with peer + elapsed (fail-loud, never abort — controllers never self-terminate). - Lane backlog → submit returns
-EAGAINwhen the cap is reached; the work is queued, never run on a drive thread; emit a metric/WARN on queue depth + oldest-age so a saturated lane is operator-visible. - Snapshot/config-push failure on the lane → reported through the existing repl/config error paths unchanged; lane only relocates execution, not error semantics.
- Dedup race → submit with an in-flight key is a no-op (
-EEXIST); idempotent.
Monitoring
- Metrics: lane queue depth, oldest-task age, in-flight count, per-peer reconnect attempts. WARN thresholds make a degraded/partitioned window visible (consistent with fail-loud).
Testing Strategy
Unit Tests
- SlowLane:
slowlane_submitis non-blocking; admission cap bounds concurrent tasks; per-key dedup returns-EEXIST;fnruns exactly once (3.1–3.4, 1.5). - BoundedConnect:
socket_connectreturns within the timeout against an unreachable host; succeeds against a reachable one (2.3). - PeerCtrlDispatch: the vtask entry returns promptly (no long op inline); per-endpoint serialization holds via
endpoint_block_tasks(4.1–4.3).
Integration Tests
- Reconnect offload: with one peer unreachable, delaymgr ticks continue and heartbeats to reachable peers are sent within the interval; reconnect is deduped per peer (2.1, 2.2, 2.5).
- Snapshot offload: a catch-up triggering a snapshot does not block iomgr; other peers' heartbeats/votes processed during the transfer; no concurrent transfer per standby (1.1, 1.3, 1.4, 1.5).
- Commit-advance under pool load (R4.5): with the vtask pool near saturation, a quorum of WAL-acks still advances
committed_lsnpromptly; cross-endpoint acks are not serialized behind one another. - Snapshot/checkpoint concurrency (R1.1/1.3/1.5): concurrent live appends + a lane checkpoint/trim never delete a segment below an in-flight snapshot boundary or a standby catch-up cursor; the produced snapshot is consistent.
- Regression sweep (R6): run the existing suites unchanged —
test_ha_election,test_ha_prevote,test_ha_liveness,test_ha_failover,test_ha_rdhup_integration,test_repl_majority/apply/catchup,test_config_repl/test_ha_repl_push,test_persist_recovery,test_commit_barrier_rollout/test_commit_durability; all pass without behavior changes (6.1–6.7).
E2E / Soak Tests
- Partition soak (R7): blackhole one peer → assert heartbeats to survivors continue, no spurious election while leader holds majority, peer rejoins on heal, zero committed-job loss (7.1–7.4).
- Failover soak (existing, kill-based) continues to pass (6.3).
Performance
- Steady-state heartbeat cadence at 3–5 controllers shows no added latency attributable to the new dispatch path; the heartbeat/vote send paths remain non-blocking enqueues (8.1, 8.2).
Migration Strategy
Phased, each phase independently testable (matches research recommendation):
Ordering rule: the slow lane is foundational — every offload (reconnect, snapshot, config-push) submits to it, so it must exist first. And the peer_ctrl→vtask move must land together with its long-op offload in the same phase: moving peer_ctrl to a vtask while still running snapshot/config-push inline would hold endpoint_block_tasks (ep->busy) across multi-second I/O (transient R4.3 violation) and consume a pool worker. The two are therefore coupled.
flowchart TD
P1[Phase 1: SlowLane executor + BoundedConnect] --> P2[Phase 2: ReconnectOffload sender-side]
P2 --> P3[Phase 3: PeerCtrlDispatch to vtask PLUS snapshot and configpush offload coupled]
P3 --> P4[Phase 4: PartitionTest + regression sweep]
- Phase 1 (foundational, lowest risk): build the SlowLane executor (bounded pool + admission + dedup — new component, no behavior change yet, unit-testable in isolation); reorder
socket_connectto bounded non-blocking +poll(independent; already bounds the delaymgr stall even before reconnect is offloaded); and add LivenessAdmission to vtaskmgr (task-class reservation; fold inCOMMIT_WAIT_RESERVED). LivenessAdmission is a hard prerequisite of Phase 3 — peer_ctrl cannot move onto the pool deadlock-safely without it — so it must land in Phase 1 as dormant infra (no behavior change until peer_ctrl dispatches through it). - Phase 2: offload the delaymgr peer reconnect to the lane (deduped per peer, registered via lock-guarded
iomgr_monitor) → closes sender-side heartbeat starvation. Shippable. - Phase 3 (coupled — the R4.3-safe step): dispatch peer-control to the vtask pool and offload the long ops it triggers (snapshot send, config-push transport-only) to the lane in the same change, so no phase ever runs long I/O inline in a blocked vtask. Carries the snapshot/checkpoint concurrency invariant (frozen
(term,lsn)boundary, trim-vs-catch-up-cursor safety, single in-flight checkpoint) and the bounded in-lane snapshot write. - Phase 4: partition test + full regression sweep.
- Rollback: each phase is behavior-preserving; a phase can be reverted independently since the contract (non-blocking drive) is the only invariant added. P1 adds dormant infra; the first observable change is P2.