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
14 KiB
Gap Analysis — raft-drive-async (2026-06-25)
Analyzes the gap between the 8 requirements and the existing controller code, to inform the design phase. Evidence from this session's blocking audits + targeted greps.
Current State Investigation
The existing deferral idiom and why it's insufficient
XCE already practices "don't block iomgr": the established pattern is delay_task_ms(&c->delaymgr, ...), which ordq_puts a callback and returns immediately. Its own comments confirm the intent — "run off the iomgr walAck thread … delay_task_ms only enqueues" (config_repl.c:2369, ha_convergence.c:207, config_repl.c:428). But the deferred callback then runs on the delaymgr thread — the heartbeat/election-timeout driver. So the only existing "offload" target is itself a liveness thread. This is the conceptual heart of the gap: the codebase can defer, but its single deferral lane is the thread the corrected ha-async-io.md contract must keep free. The missing capability is a non-liveness execution lane (a "slow lane").
Inline-on-iomgr root cause
peer_ctrl_msg_handler is assigned directly as ep->msg_handler at ha.c:4265 (on_vote_connected), ha.c:4307 (on_pre_vote_connected), ha.c:6349 (standby accept) — so it runs on the iomgr loop. Normal inbound messages instead go handle_msg → vtask_handle_msg → vtask pool (cluster.c:711, registered cluster.c:772). The peer-control path is the anomaly that bypasses the pool.
Reusable building blocks that already exist
- vtask submit primitives:
vtask_alloc/vtask_init(vtaskmgr.c:21) /vtask_enq(vtaskmgr.c:342) exist and compose; the only public entryvtask_handle_msg(vtaskmgr.c:356) hardcodesvtype=VTASK_IOand requiresep->fsm_in.pkt. A general non-IO submit is a small composition of the existing primitives, not new infrastructure. - Per-endpoint serialization (explicit primitive):
endpoint_block_tasks()/endpoint_unblock_tasks()(netio.c:896-911) set a per-endpointep->busyflag; the VTASK_IO path auto-blocks on task start (vtaskmgr.c:249), unblocks on completion (vtaskmgr.c:66), and the handler path uses it (xcectx.c:440/449), whilevtask_eligible(vtaskmgr.c:203) skips a busy endpoint's other tasks. This directly and explicitly satisfies R4.1 (per-connection processing order) when peer control reuses the IO path — a reuse, not new logic. Cross-peer isolation is preserved (block is per-ep, so peer X busy ≠ peer Y blocked). Design trap: the block is held for the whole task duration — so a long op (snapshot send) run inline in a blocked vtask would queue that peer's own subsequent heartbeat-ACKs behind it. This makes the two-tier rule (fast work in vtask, trigger long I/O to the slow lane) mandatory for R4.1 + R1 to both hold. - Admission cap pattern:
COMMIT_WAIT_RESERVED=2ofnvcpus(default 8) caps blocking commit-waits atmax(1, n-2)(commit.c:200, commit.h:17) — a reusable pattern for R3, but it is commit-engine-specific today. - Dedicated-background-thread precedent:
fsync_worker(persist.c:624) is a long-lived background thread;push_config_transport_onlyalready spawns per-standbypush_threads (config_repl.c:1412) — then blocks bypthread_join/wait_all_with_timeouton the drive thread (config_repl.c:1332). So the threads exist; the defect is the blocking join on the caller, not absent concurrency. - Bounded-connect pattern:
xce_probe_config_tagalready doesO_NONBLOCK-before-connect +poll(500ms)(config_repl.c:1753-1761). The buggy path (socket_connect, netio.c:289/302) sets non-blocking after connect (netio.c:716) — so R2.3 is largely a reorder + reuse, not new code.
Confirmed absent
No iomgr cross-thread wake primitive— CORRECTED 2026-06-25 (was overstated). epoll registration is ALREADY thread-safe:iomgr_monitor(EPOLL_CTL_ADD),iomgr_unmonitor(DEL),iomgr_update_poll(MOD) all wrapepoll_ctlinmylock_lock(&iomgr->lock)(netio.c:1046-1082), andepoll_ctlis kernel-thread-safe regardless. The connect→register flow already runs from delaymgr/main threads today (e.g.on_vote_connected). So a worker can do the blocking connect off-thread, fully prepare the endpoint (setep->msg_handleretc.) BEFORE it is monitored (an unmonitored fd yields no events → no race), then call the lock-guardediomgr_monitor. No new eventfd/iomgr-post primitive is needed; the marshal-back is "reuseiomgr_monitor." Residual (design-verify, not a primitive): ensure worker-side handling does not concurrently touch an endpoint's read-FSM buffer that iomgr owns — already mitigated byendpoint_block_tasksper-endpoint serialization + lock-guarded poll changes.- No network-partition test primitive: the soak (
run-ha-soak.sh) is process-kill only (kill -9, lines 74/440); unit "unreachable peer" cases use closed ports, not traffic drops. R7 has no existing harness.
Requirement → Asset Map
| Req | Existing asset | Gap tag |
|---|---|---|
| R1 liveness during snapshot/config I/O | snapshot send ha.c:2702+config_repl.c:2446; config-push threads config_repl.c:1412 |
Missing off-load lane for snapshot; Constraint config-push blocks on join |
| R2 unreachable-peer / bounded connect | endpoint_connect/socket_connect netio.c:289/302 (blocking); xce_probe_config_tag pattern (reuse); reconnect via delaymgr primary_heartbeat_fn |
Constraint (reorder connect) + Missing (offload reconnect off delaymgr) |
| R3 admission protection | COMMIT_WAIT_RESERVED pattern (commit-specific) |
Missing (generalize to the slow lane) |
| R4 ordering/correctness | endpoint_block_tasks/endpoint_unblock_tasks per-endpoint sequential primitive (netio.c:896-911, auto on VTASK_IO path); lock-guarded iomgr_monitor/iomgr_unmonitor (netio.c:1046-1082) for thread-safe epoll registration |
Reuse (both primitives exist; no new marshal-back primitive needed) |
| R5 contract compliance | ha-async-io.md contract (steering authority) |
verified by R1–R4 (no standalone asset) |
| R6 no regression | existing suites: test_ha_election/prevote/liveness/failover/rdhup_integration, test_repl_majority/apply/catchup, test_config_repl/test_ha_repl_push, test_persist_recovery, test_commit_barrier_rollout/test_commit_durability |
Reuse as regression gate |
| R7 partition verification | run-ha-soak.sh (kill-only) |
Missing (no traffic-drop primitive) |
| R8 heartbeat perf | heartbeat fan-out already non-blocking enqueue | Constraint (don't regress fast path) |
Implementation Approach Options
Option A — Extend in place (minimal)
Dispatch peer_ctrl_msg_handler through the existing handle_msg/vtask_handle_msg path; add a general non-IO vtask_enq wrapper; reorder socket_connect to non-blocking + poll; replace config-push wait_all_with_timeout join with a non-blocking completion check; reuse delay_task_ms only for non-blocking triggers. Touches ha.c, config_repl.c, netio.c, vtaskmgr.c.
- ✅ Least new surface; reuses every existing pattern; fastest path to closing the delaymgr+iomgr inline blocks.
- ❌ Long I/O (snapshot, config-push) still lands in the request vtask pool → relocates the stall to pool exhaustion (R3 only partially met).
Option B — New dedicated slow-lane executor
Build a dedicated bounded slow-lane executor (generalize fsync_worker) for all long blocking I/O. Peer control still dispatched to vtask, but heavy work re-offloaded to the slow lane. (No iomgr-post primitive needed — epoll registration reuses the lock-guarded iomgr_monitor.)
- ✅ Clean separation; R3/R4 fully met by construction; isolates seconds-long I/O from request handling and from delaymgr.
- ❌ One genuinely new component; more interface design than extending in place.
Option C — Hybrid (recommended)
- Extend (reuse): peer_ctrl → vtask via the existing dispatch pattern; general non-IO vtask submit (compose
vtask_alloc/init/enq); bounded connect (reorder + reusexce_probe_config_tagpattern); preserve ordering viaVTASK_IOserialization. - New (the one real gap): a small bounded slow-lane executor for snapshot-transfer + config-push (generalize the
fsync_worker/push_threadprecedent; per-peer dedup + admission cap parallel toCOMMIT_WAIT_RESERVED). Epoll registration of a connected fd reuses the lock-guardediomgr_monitor(no new primitive). - Phased:
- Phase 1 (S–M, highest value/lowest risk): bounded connect + offload reconnect off delaymgr → closes the heartbeat sender-side starvation (R2). Independent, shippable.
- Phase 2 (M): dispatch peer_ctrl to vtask (epoll registration reuses lock-guarded
iomgr_monitor) → closes the iomgr receiver-side inline processing (R4, R5). - Phase 3 (L): slow-lane executor + move snapshot-send/config-push to it + admission generalization (R1, R3).
- Phase 4 (M): partition test harness + regression sweep (R6, R7, R8).
- ✅ Each phase is independently testable and de-risks the next; matches what already exists vs. what's truly missing.
- ❌ Requires phase coordination; must keep
ha-async-io.mdcompliance consistent across phases.
Effort & Risk
| Work item | Effort | Risk | Justification |
|---|---|---|---|
| Bounded connect + reconnect offload (Ph1) | S–M | Medium | reorder existing pattern; offload target needs to be non-delaymgr |
| General non-IO vtask submit | S | Low | compose existing vtask_alloc/init/enq |
| peer_ctrl → vtask dispatch | M | Medium | correctness-sensitive: ordering (mitigated by endpoint_block_tasks), term/vote semantics; epoll register reuses lock-guarded iomgr_monitor |
| Slow-lane executor + snapshot-send offload | L | Medium-High | per-peer dedup + coordination with ongoing append/WAL-trim/catch-up stream |
| Admission generalization (R3) | M | Medium | extend COMMIT_WAIT_RESERVED pattern to slow lane |
| Partition test harness (R7) | M | Medium | no traffic-drop primitive today; soak infra is kill-only |
| Overall | L (1–2 wks) | Medium | riskiest: snapshot-offload coordination + moving peer_ctrl off iomgr (no new epoll-core primitive needed) |
Recommendations for Design Phase
- Preferred approach: Option C (Hybrid), phased. Ship Phase 1 (bounded connect) early — it independently closes the sender-side heartbeat starvation and is low-risk.
- Key decisions to settle in design:
- Connected-fd registration: reuse the lock-guarded
iomgr_monitor(netio.c:1058) — a worker prepares the endpoint fully, then registers it; no new primitive. Design-verify only that worker handling does not touch an endpoint's read-FSM buffer concurrently with iomgr (serialized byendpoint_block_tasks). - Slow-lane shape: a shared bounded pool vs. reuse the existing per-standby
push_threadmodel for config-push and a small pool for snapshot. Size to peer count. - Fold-in question — RESOLVED 2026-06-25: keep
raft-drive-asyncSTANDALONE. Scope-check verdict ADJACENT-SAME-AREA:election-iomgr-refactoris fully implemented and fixed the startup blocking path (pthread election beforecluster_run); it touched none of raft-drive-async's runtime targets (peer_ctrl_msg_handlerdispatch, delaymgrconnect, snapshot/config-push on iomgr) and explicitly deferred async DNS as a non-goal. It is consumed as completed upstream, providing reusable primitives (delay_task_ms,on_connectedfire-once callback, theha_start_election_async/non-blocking vote-fan-out contract). Folding in would reopen a done spec for net-new infra it never held.
- Connected-fd registration: reuse the lock-guarded
Research Needed (carry to design)
election-iomgr-refactor scope overlap— RESOLVED 2026-06-25 (scope-check): ADJACENT-SAME-AREA, keep standalone; reuse its primitives (delay_task_ms,on_connected,ha_start_election_async). See Recommendations decision #3.- Endpoint read-FSM ownership: confirm worker-side peer-control handling never touches an endpoint's read-FSM buffer concurrently with the iomgr read path (expected safe via
endpoint_block_tasks+ handoff offsm_in.pkt). (Epoll registration itself is already settled — lock-guardediomgr_monitor; no new primitive.) - Partition primitive choice for R7:
iptables/tc(root, infra-level) vs. an in-process socket-drop hook inxce_test. Determines test portability. - Snapshot-send coordination with the concurrent WAL-catch-up stream and the primary's append/
trim_wal_segmentswhile the transfer runs on the slow lane. - Admission interaction between the existing
COMMIT_WAIT_RESERVED(commit waits) and the new slow-lane cap — shared budget vs. independent, so the two together can't starve the 8-worker pool.
Design Synthesis (2026-06-25, /kiro-spec-design)
Key simplification chosen at design time: the offload target for long blocking I/O is a dedicated slow-lane executor (separate threads), NOT the vtask request pool. Consequences:
- R3 (pool protection) becomes structural — long ops never run on the vtask pool, so they cannot starve liveness-critical message handling. The existing
COMMIT_WAIT_RESERVEDcontinues to cover commit-waits. - The "general non-IO vtask submit" research item is SUPERSEDED — not needed. Peer-control handling is packet-triggered, so it reuses the existing VTASK_IO
vtask_handle_msgpath; the only packet-less trigger (delaymgr reconnect) goes to the slow lane, not the vtask pool. No new vtask submit primitive required. - Combined with the already-refuted iomgr-post primitive (epoll registration reuses lock-guarded
iomgr_monitor), the only genuinely-new component is the slow-lane executor (slowlane.{c,h}). Everything else is reuse + integration.
Build-vs-adopt: slow lane generalizes the existing fsync_worker/push_thread background-thread precedent (adopt pattern, build the small bounded executor). Bounded connect reuses the xce_probe_config_tag pattern (reorder, not new). Per-endpoint ordering reuses endpoint_block_tasks.
Phased migration (P1 bounded-connect → P2 peer_ctrl→vtask → P3 slow-lane+offload → P4 partition test) keeps each step behavior-preserving and independently revertible.