Investigation & fixes (failover committed-job loss): - root-cause bug-851 on an owned 3-node cluster: chaotic failover causes silent committed-prefix divergence (WAL hdr.term always 0; two leaders per term; no (term,lsn) reconcile). Evidence in repro-851/ + INVESTIGATION. - live-validated fixes 848/849/850 (recovery-on-promotion, advertise real applied_lsn, persist-layer contiguity guard); add waldump WAL inspector. Specs (strict raft.tla conformance — library owns the log): - TLA-gap-analysis.md: impl vs raft/raft.tla, gaps G1-G6. - raft-log-replication, raft-commit-safety, raft-sim-conformance: extend src/lib/raft to own log/commit/verification via callbacks (specs generated, cross-spec review passed). - ha-term-safety: controller-side adapter + config marker (R1-R7); design done. - config-upgrade-snapshot: R8 descoped here (deps entity-bearing-snapshots, bug-857). - roadmap + buglog (851/854/857) + .wolf knowledge updated.
46 KiB
Research & Design Decisions
Summary
- Feature:
election-iomgr-refactor - Discovery Scope: Complex Integration
- Key Findings:
endpoint_connect()already sets SSL client mode and triggers epoll-driven async handshake — almost exactly what outgoing vote connections need. Gap: blocking DNS + no "handshake done" callback.- The main thread can safely block on
sem_timedwait(&ctx->done)while iomgr runs in its own thread. This is identical to the heartbeat watchdog pattern already in ha.c. - Incoming vote replies via iomgr (
myreq_handler → case OPCode_requestVote:) already work. We only need to addcase OPCode_reply:withdata.requestvotereplymsgto handle the vote reply on the receiver side, and a newhandle_vote_reply_from_ep()function for outgoing endpoints.
Research Log
iomgr Outgoing Connection Support
- Context: Can iomgr manage outgoing vote connections instead of ad-hoc pthread raw socket I/O?
- Sources: netio.c lines 654–678, 432–460, 962–990; ha.c lines 1388–1430; config_repl.c connect_ssl_raw
- Findings:
endpoint_connect()does blocking DNS (gethostbyname in socket_connect) then sets SSL client state and calls handle_ssl_handshake() once. The rest is epoll-driven.handle_ssl_handshake()uses SSL_do_handshake() (non-blocking); retries on WANT_READ/WANT_WRITE via subsequent epoll events.endpoint_calc_flags()arms EPOLLOUT during handshake phase automatically.iomgr_monitor()uses EPOLL_CTL_ADD with EPOLLIN only; endpoint_update_epoll() adjusts flags dynamically.- Gap: no "handshake completed" callback on endpoint_t. Solution: add
on_connectedcallback invoked by io_handler after ssl_handshake_done transitions from 0 to 1.
- Implications: Minimal netio.c change (add callback field + invocation); no new epoll machinery needed.
Election Timeout Mechanism
- Context: How to implement per-round election timeout without blocking the event loop?
- Sources: ha.c ha_arm_heartbeat_timeout_vtask (line ~1658), am_i_primary_quorum sem_wait (line 1339)
- Findings:
- Main thread blocks on
sem_wait(&ctx->done)while iomgr + vote-reply threads run — this is safe. - Replace
sem_waitwithsem_timedwaitusingelection_timeout_msdeadline. On ETIMEDOUT, set ctx->result=0 and proceed to evaluate. - This mirrors the heartbeat watchdog pattern exactly. No extra thread needed.
- Main thread blocks on
- Implications: Two-line change to am_i_primary_quorum; no new timer infrastructure required.
Vote Reply Routing
- Context: Who calls handle_request_vote_reply() in the new design?
- Sources: cluster.c myreq_handler, ha.c handle_request_vote_reply
- Findings:
- Outgoing vote endpoints will receive
OPCode_replypackets (since the server sends via xce_reply → OPCode_reply). - The outgoing endpoint needs a
msg_handlerthat decodes the RequestReply, extracts the requestvotereplymsg, and calls handle_request_vote_reply(). - This handler runs in vtaskmgr context (from io_handler → vtask_handle_msg), which is safe since ha_elect_ctx_t operations are mutex-protected.
- Outgoing vote endpoints will receive
- Implications: New
vote_reply_msg_handler()function registered on each outgoing endpoint. No new threading.
SSL Context for Outgoing Vote Connections
- Context: The bug was server-method SSL context used for SSL_connect.
- Findings:
endpoint_connect()callsSSL_new(iomgr->ssl_ctx)thenSSL_set_connect_state(). Since iomgr->ssl_ctx was created with TLS_server_method(), SSL_set_connect_state() on a server context is undefined behavior. - Selected approach: Create a dedicated
client_ssl_ctx(TLS_client_method, SSL_VERIFY_NONE) stored in ha_state_t. Pass it to endpoint_connect viaendpoint_set_ssl_ctx(ep, ha->client_ssl_ctx)before connecting. - Rationale: Separating server and client contexts is the correct OpenSSL pattern. The client context does not need a certificate — vote RPC security comes from the term/config_tag payload, not TLS cert chain.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks |
|---|---|---|---|
| Keep pthreads | Fix SSL context + UAF bugs only | Minimal code change | Same architectural complexity; new bugs likely |
| Full iomgr-native | Outgoing vote via endpoint_connect + on_connected callback + reply via msg_handler | One I/O loop, no UAF possible, matches existing patterns | Requires netio.c and ha.c changes; more complex |
| Hybrid: iomgr connect, pthread reply | Use iomgr for connect/handshake, pthread to wait for reply | Partial improvement | Still two I/O paths; UAF still possible |
Selected: Full iomgr-native. The minimal netio.c change (add on_connected callback) enables a clean design with no mixed-I/O issues.
Design Decisions
Decision: on_connected callback in endpoint_t
- Context: Need to know when SSL handshake completes on outgoing vote connection so we can send the vote RPC.
- Alternatives: (1) Poll ssl_handshake_done in main thread — requires busy-wait. (2) Add callback — clean, one extra field.
- Selected: Add
void (*on_connected)(endpoint_t *ep, void *ctx)to endpoint_t. Set ctx pointer via endpoint_set_conn_userdata(). - Rationale: Mirrors msg_handler and err_handler patterns already on endpoint_t.
Decision: sem_timedwait for election timeout
- Context: Need election round to time out if no replies arrive.
- Alternatives: (1) Delaymgr — 200ms polling jitter, designed for recurring tasks. (2) Separate watchdog thread. (3) sem_timedwait inline in am_i_primary_quorum.
- Selected: sem_timedwait with election_timeout_ms deadline.
- Rationale: Identical to heartbeat watchdog pattern; no extra thread; precise CLOCK_REALTIME deadline.
Decision: client_ssl_ctx stored in ha_state_t
- Context: Need TLS_client_method context for outgoing connections; iomgr uses TLS_server_method.
- Selected: Create client_ssl_ctx in myapp_run alongside ssl_ctx; store in ha_state_t; set on outgoing endpoints before connect.
- Rationale: Clear ownership, no per-connection allocation, reused across election rounds.
Risks & Mitigations
- DNS blocking in endpoint_connect(): socket_connect uses gethostbyname. For loopback/localhost addresses (test environment), this resolves instantly. For production, replace with getaddrinfo + async resolver if needed — deferred.
- on_connected callback races: The callback fires in iomgr's io_worker thread. ha_elect_ctx_t already uses a mutex. All handle_request_vote_reply calls are mutex-protected — safe.
- outgoing endpoint lifetime: The outgoing endpoint must outlive the election round. Endpoints are stored in a local array in am_i_primary_quorum and cleaned up after sem_timedwait returns.
Gap Analysis (Updated)
Analysis date: 2026-06-01. Phase: tasks-generated / ready_for_implementation. This section audits the current implementation state vs. the 9-requirement spec to determine what remains before the spec can be marked complete.
Analysis Summary
- Scope: election-iomgr-refactor — replace blocking pthread election with full iomgr-native async path; add COMMITTING gate and majority-ack availability gate.
- Implementation status: ~90% complete. All 15 core implementation tasks (1.1–5.3) are confirmed done. Integration tests 6.1–6.3 are in meson.build and test files exist. Only tasks 6.4 and 6.5 remain.
- Remaining work: (1) RDHUP failover integration test (
test_ha_rdhup_failover.cor similar); (2) full regression + ASAN verification pass. - Recommendation: The chosen approach (full iomgr-native, raft-library backed) is already implemented. No architecture changes needed. Proceed to task 6.4 directly.
- Effort remaining: S (1–2 days). Risk: Low.
Requirement-to-Asset Map
| Requirement | Asset | Status |
|---|---|---|
| Req 1: No blocking threads for vote RPCs | ha_start_election_async in ha.c; old am_i_primary_quorum / vote_request_thread removed |
✅ Done |
| Req 2: Async outgoing vote via iomgr | on_vote_connected, vote_ep_ctx_t, endpoint_set_on_connected (netio.c:782), client_ssl_ctx in ha_state_t |
✅ Done |
| Req 3: Vote reply via iomgr dispatch | vote_reply_msg_handler, peer_ctrl_msg_handler, capnp fields read before xce_free_request |
✅ Done |
| Req 4: Quorum, ground-truth, timeout, retry | ha_elect_evaluate_and_signal (quorum math), raft_on_vote_request (term/voted_for), election_timeout_fn, raft retry callbacks |
✅ Done |
| Req 5: FSM state consistency | ha_dispatch_gate extended for CTRL_PRIMARY_COMMITTING; CTRL_ELECTING gate |
✅ Done |
| Req 6: Role-based vote denial | handle_request_vote role gate; ha_heartbeat_is_fresh; RDHUP → ha_on_primary_ctrl_fd_rdhup |
✅ Done |
| Req 7: Post-election config + WAL replication | push_config_to_standbys, ha_send_ctrlhello_to_all_standbys in cluster.c |
✅ Done |
| Req 8: Availability gate | ha_repl_ack_received majority check; committing_timeout_fn degraded fallback |
✅ Done |
| Req 9: No regressions | cluster_init legacy wrapper preserved; single-node shortcircuit in ha_start_election_async |
✅ Done (pending 6.5 verification) |
Gaps and Constraints
Gap 1: Task 6.4 — RDHUP failover integration test (MISSING)
- What's needed: A Unity test that verifies: SIGKILL primary → standby detects heartbeat timeout → CTRL_ELECTING → CTRL_COMMITTING → CTRL_PRIMARY via
ha_rdhup_election_hook+ hook infrastructure. - Pattern to follow:
test_p2_integration.cuses in-process persist + no live network;test_ha_failover.c(already exists, 0 bytes based on meson but check) ortest_ha_connection_reuse.cfor the RDHUP hook pattern. - Where to add: New file
testsuite/test_ha_rdhup_integration.c; add to meson.build alongsidetest_ha_failover.c. - Key hooks to use:
ha_rdhup_election_hook(defined in ha.h underXCE_TESTING),ha_phase2_hook(already used intest_ha_election.c),ha_push_config_hook,ha_send_ctrlhello_hook. - Complexity: Low — all test seams already exist. This is wiring hooks together, not new functionality.
Gap 2: Task 6.5 — Regression + ASAN clean (PENDING CI)
- What's needed: Run
meson test --suite controllerunderASAN_OPTIONS=detect_leaks=0; verify count ≥ baseline; no new ASAN errors. - Status: Cannot confirm without running the build. The test files exist and are in meson.build. The main risk is that
test_ha_rdhup_integration.c(6.4) is the final piece before 6.5 is runnable. - Action: Run
/xce-build controllerafter 6.4 is written.
Constraint: ha_wait_for_heartbeat_or_timeout still compiled
- Observation:
ha_wait_for_heartbeat_or_timeoutandam_i_primarystill exist in ha.c (not deleted — the old implementation is preserved forha_dispatch_gateand other legacy paths). - Impact: Not a blocking issue. The function is no longer called from myapp_run. It's dead code but not harmful. May be removed in cleanup pass.
- Research Needed: Grep callsites before deleting to ensure nothing else calls it.
Current State Investigation
Existing Assets (relevant to remaining work)
Test infrastructure already in place:
ha_rdhup_election_hook— declared in ha.h underXCE_TESTING; used inha_on_primary_ctrl_fd_rdhupto inject mock electionha_phase2_hook— used intest_ha_election.cto count phase2 invocations without real clusterha_push_config_hook,ha_send_ctrlhello_hook— used in evaluate_committing testsha_tcp_connect_hook— for injecting socketpair fds instead of real TCP
Test files for 6.4 reference:
test_ha_election.c(985 lines) — shows how to set up ha_state_t + ha_elect_ctx_t + hooks for unit testingtest_ha_evaluate_committing.c(444 lines) — shows COMMITTING path test patterntest_ha_failover.c— exists in meson.build (line 112); may already cover parts of 6.4
Key flow for task 6.4 test:
- Create
ha_state_twith CTRL_STANDBY + live heartbeat timestamp - Set
ha_rdhup_election_hookto a stub that immediately callsha_elect_evaluate_and_signalwith 1-node PRIMARY result - Set
ha_phase2_hookto no-op (phase2_done=1 simulating post-first-election standby) - Call
ha_on_primary_ctrl_fd_rdhup(ha, NULL)— should: set CTRL_ELECTING, call hook, evaluate → COMMITTING → PRIMARY - Assert final role = CTRL_PRIMARY
Implementation Approach Options
Option A: Write minimal 6.4 test using existing hooks (Recommended)
- Files to create:
testsuite/test_ha_rdhup_integration.c(estimate 150–200 lines) - Files to modify:
meson.build— add new test file - Why: All test seams exist; pattern is established in test_ha_election.c and test_ha_evaluate_committing.c. No production code changes needed.
- Trade-offs: ✅ Fast; ✅ ASAN-clean by design (hooks avoid real network); ❌ In-process only (not a true 3-process integration test — but matches existing test philosophy)
Option B: Extend test_ha_failover.c if already started
- Files to modify:
testsuite/test_ha_failover.cif it exists and has content - Why: Avoids new file; keeps related tests together
- Trade-offs: ✅ Co-locates failover tests; ❌ Need to verify file has content first
Option C: Live 3-process integration test via deploy-ha.sh
- Scope: Shell script test using
deploy-ha.sh+ log parsing - Why: Most realistic; validates actual process behavior
- Trade-offs: ✅ Full end-to-end; ❌ Slow; ❌ Non-deterministic timing; ❌ Not part of
meson testsuite — better as a separate CI job
Effort and Risk
| Item | Effort | Risk | Justification |
|---|---|---|---|
| Task 6.4 (RDHUP test) | S (half day) | Low | All test seams exist; pattern established |
| Task 6.5 (regression) | S (1 hour CI run) | Low | Code already in place; just needs build + test run |
| Remove dead code (ha_wait_for_heartbeat_or_timeout) | S | Low | Optional cleanup; no impact on correctness |
Recommendations for Design Phase
This spec is already past the design phase and into implementation. The recommendations are:
-
Proceed directly to task 6.4: Write
test_ha_rdhup_integration.cusing Option A. Theha_rdhup_election_hook+ha_phase2_hook+ha_push_config_hookseams cover all assertions needed without live network I/O. -
Check test_ha_failover.c content first: If it already has RDHUP tests (it's in meson.build), extend rather than create a new file.
-
Run
/xce-build controllerfor 6.5: After 6.4 is written, run the full test suite to confirm no regressions. Watch for ASAN errors in the newvote_ep_ctx_tallocation/free path. -
Deferred research item — DNS blocking:
endpoint_connectuses blockinggethostbyname. For localhost test targets this is instant. For production multi-host clusters, this becomes a delaymgr task concern. Mark as a follow-up for the ha-peer-connection-reuse spec.
Cross-Spec Gap Audit (2026-06-03)
Triggered by:
/kiro-validate-gap ha-config-bootstrap election-iomgr-refactor, check am_i_primary() still be used?
Analysis Summary
- This spec is 100% complete (phase:
implemented). No gaps remain. am_i_primary()is dead code — not called from any production path. See ha-config-bootstrap/research.md for full analysis.- The stale comment at cluster.c:1015 (
/* Determine initial role based on am_i_primary() result set by myapp_run */) should be updated in a cleanup pass to referenceha_elect_evaluate_and_signal. ha_wait_for_heartbeat_or_timeoutis also dead code (confirmed: removed from myapp_run per task 5.1/5.3); its function body in ha.c is unreachable from production entry points.
Deferred Cleanup Items
| Item | File | Action |
|---|---|---|
am_i_primary() function body |
ha.c:869 | Delete; also delete 3 test cases in test_ha_role.c |
start_heartbeat_listener() / stop_heartbeat_listener() stubs |
ha.c:951-960 | Delete (no-op stubs, no callers) |
Stale comment about am_i_primary |
cluster.c:1015 | Update to reference ha_elect_evaluate_and_signal |
ha_wait_for_heartbeat_or_timeout |
ha.c | Delete (removed from myapp_run, no other callers) |
None of these affect correctness or test pass rate. Safe to clean up in a single dedicated commit.
Gap Analysis — Completion Audit (2026-06-02)
Analysis date: 2026-06-02. Phase:
implemented. All tasks[x]. Purpose: confirm full coverage of the 9 requirements against the live codebase before closing the spec.
Analysis Summary
- Scope: election-iomgr-refactor — async iomgr election, COMMITTING gate, majority-ack availability gate, RDHUP failover wiring.
- Implementation status: 100% complete. All 15 implementation tasks (1.1–5.3) and all 5 validation tasks (6.1–6.5) are marked done and verified in the live codebase.
- No remaining gaps: The previous gap (tasks 6.4, 6.5) was closed by
test_ha_rdhup_integration.c(6 test cases, all wired intocontroller_test.c) and the full test suite build. - Deferred item still open: DNS blocking in
endpoint_connect(gethostbyname) — non-blocking for localhost; tracked as a follow-up forha-peer-connection-reusespec. - Effort remaining: Zero. The spec can be marked closed.
Requirement-to-Asset Map — Final State
| Requirement | Key Assets | Verified |
|---|---|---|
| Req 1: No blocking vote threads | ha_start_election_async, raft_start_election; vote_request_thread / am_i_primary_quorum removed from myapp.c |
✅ |
| Req 2: Async outgoing vote via iomgr | endpoint_set_on_connected (netio.c:782), on_connected callback (netio.c:441), vote_ep_ctx_t, client_ssl_ctx in ha_state_t |
✅ |
| Req 3: Vote reply via iomgr dispatch | vote_reply_msg_handler; capnp fields read before xce_free_request; vote_reply_dispatch / raft_on_vote_reply |
✅ |
| Req 4: Quorum, ground-truth, timeout, retry | ha_elect_evaluate_and_signal quorum math; raft_election_timeout_fn; raft_on_vote_request term/freshness gate; raft retry via raft_on_vote_reply |
✅ |
| Req 5: FSM state consistency | ha_dispatch_gate — CTRL_PRIMARY_COMMITTING case allows replication ops, denies job ops; CTRL_ELECTING case; CTRL_STANDBY case |
✅ |
| Req 6: Role-based vote denial | handle_request_vote role gate; ha_heartbeat_is_fresh; RDHUP → ha_on_primary_ctrl_fd_rdhup → ha_start_election_async |
✅ |
| Req 7: Post-election config + WAL replication | push_config_to_standbys (ha.c:420-425); ha_send_ctrlhello_to_all_standbys (ha.c:435); ha_start_primary_heartbeat (ha.c:441) |
✅ |
| Req 8: Availability gate | ha_repl_ack_received majority check; committing_timeout_fn degraded fallback; ha_promote_to_primary sets CTRL_PRIMARY |
✅ |
| Req 9: No regressions | Single-node shortcircuit in raft_start_election; cluster_init legacy wrapper; test_ha_rdhup_integration.c 6 RDHUP scenarios all PASS |
✅ |
Validation Coverage
| Task | Test File | Cases | Status |
|---|---|---|---|
| 6.1 Election + committing timeouts | test_ha_election.c, test_ha_evaluate_committing.c |
30+ | ✅ |
| 6.2 FSM gate + vote denial | test_ha_vote_gate.c, test_ha_role.c |
15+ | ✅ |
| 6.3 3-node startup integration | test_ha_rdhup_integration.c (hook-based) |
6 | ✅ |
| 6.4 RDHUP failover integration | test_ha_rdhup_integration.c |
6 | ✅ |
| 6.5 Regression + ASAN | Full meson test --suite controller |
230+ | ✅ |
Constraints / Deferred Items
| Item | Status | Tracking |
|---|---|---|
gethostbyname blocking in endpoint_connect |
Deferred — safe for localhost clusters | ha-peer-connection-reuse spec |
ha_wait_for_heartbeat_or_timeout dead code |
Still compiled; no callers in production path | Low priority cleanup |
am_i_primary still compiled (legacy) |
Not called from myapp.c; kept for unit-test compatibility |
Can be removed in cleanup pass |
Conclusion
The election-iomgr-refactor spec is fully implemented and verified. No blocking issues remain. The spec phase is implemented and can be closed. The one deferred item (DNS blocking) is tracked under ha-peer-connection-reuse.
Cross-Spec Gap Audit (2026-06-08)
Batch
/kiro-validate-gapacross 9 specs on persistent_ha branch.
Status: CLOSED — No remaining gaps
All 15 implementation tasks (1.1–5.3) and all 5 validation tasks (6.1–6.5) confirmed done. The spec is in implemented phase. Deferred cleanup items (dead code: am_i_primary, ha_wait_for_heartbeat_or_timeout, start_heartbeat_listener/stop_heartbeat_listener stubs) are cosmetic and tracked as tech debt.
Gap Analysis — Validation Pass (2026-06-09)
Spec phase:
implemented. This pass audits requirements-vs-CURRENT-codebase drift, analyzing the working tree as it is on disk — including uncommitted changes toha.c,ha.h,myapp.c, andsrc/lib/raft/raft.c(postha-repl-hardening/ "finalize configuration replication" work).
Analysis Summary
- Implementation status: 8 of 9 requirements Implemented in the current tree; Req 9 (no regression) is Partial — the working tree has 2 failing controller unit tests introduced by the uncommitted
ha.cvote-path changes (verified by building and runningcontroller_unit_test). - Architecture drift since the spec was written (benign): the election core was extracted into a reusable raft library (
src/lib/raft/raft.c), promotion now uses heartbeat-reply majority convergence (ha_check_majority_convergence) as the primary mechanism withha_repl_ack_receivedretained, the committing timeout default moved 10s → 60s, and raft init moved from per-election (ha_start_election_async) to one-time startup (ha_init_raft, uncommitted). All of these still satisfy the requirement text. - Real gaps: (1) two unit tests contradict the new vote-deny semantics; (2) semantic divergence between the legacy
handle_request_votepath (ha.c) andraft_on_vote_request(raft.c) on duplicate-vote handling; (3) the newmyapp_runskip-election "self" sentinel does not match production self-vote naming; (4) stale comments + known dead code. - Effort to close: S (≤1 day). Risk: Medium (election-safety semantics must be decided deliberately, not just patched to make tests green).
Requirement-to-Asset Map
| Req | Summary | Current Assets (on-disk) | Status |
|---|---|---|---|
| 1.1–1.3 | No blocking vote pthreads; vote RPC via standard I/O; probe timeout = deny | ha_start_election_async (ha.c:3030) delegates to raft_start_election; vote_request_thread/am_i_primary deleted (tombstones ha.c:885–887, ha.h:211); timeout via raft_cb_schedule_timeout → delay_task_ms |
Implemented |
| 2.1–2.4 | Async TCP+TLS via iomgr; TLS client ctx; send vote after handshake; connect failure = deny | endpoint_set_on_connected (netio.c:782, fired at netio.c:441–443 with fire-once semantics); ha->client_ssl_ctx applied in vote-ep setup (ha.c:160–167); on_vote_connected (ha.c:2291); vote_ep_err_handler (ha.c:2945+) |
Implemented |
| 3.1–3.4 | Reply via iomgr dispatch; no capnp UAF; field extraction; term fencing | vote_reply_msg_handler + role-routing peer_ctrl_msg_handler (ha.c:2565–2579); fields read before free; term fencing in raft_on_vote_reply (raft.c) |
Implemented |
| 4.1 | Quorum → CTRL_PRIMARY_COMMITTING | raft_cb_become_leader → role store (ha.c:505–507) |
Implemented |
| 4.2–4.3 | Freshness gate = ground truth; cold start only highest config_tag wins | raft_cb_vote_eligible app-policy gate invoked from cluster.c:331–373 (raft_on_vote_request step 5); tag=0 node refuses to campaign (ha.c:3133–3145) |
Implemented |
| 4.4–4.5, 4.7 | Split-vote/no-reply retry; retry indefinitely | raft_on_election_timeout (raft.c:250), raft_on_leader_lost (raft.c:268) with priority-scaled backoff; heartbeat watchdog re-arms (myapp.c skip-election path now arms ha_arm_heartbeat_timeout_vtask — uncommitted fix) |
Implemented |
| 4.6 | ELECTING + heartbeat from new PRIMARY → STANDBY | Achieved via an equivalent mechanism, not literal heartbeat: ha_dispatch_gate denies heartbeat/ctrlhello in CTRL_ELECTING; the candidate instead becomes STANDBY via vote-grant → raft_cb_become_follower (ha.c:596–659), and the winner's ctrlhello is retried each heartbeat tick once the loser is STANDBY |
Implemented (mechanism drift vs. requirement wording — document, don't change) |
| 5.1–5.4 | Dispatch-gate per role | ha_dispatch_gate (ha.c:1412): ELECTING allows requestVote+probeConfigTag only; COMMITTING allowlist (ha.c:1436–1454); STANDBY allowlist; PRIMARY full. Note: CTRL_INIT now also admits OPCode_heartbeat (deliberate extension for INIT→STANDBY) |
Implemented |
| 5.5 | Monotonic persisted term | ha_persist_term/ha_restore_term (ha.c:1647/1694); raft_cb_persist (ha.c:677) persists before grant; ha_init_raft (uncommitted, ha.c:2984) seeds raft from restored term once at startup — fixes term reset across re-elections |
Implemented |
| 6.1 | PRIMARY/COMMITTING deny all votes | raft.c role gate (step 3, equal-term challenger) + ha.c:1838 legacy gate + term-ordered demote-before-grant (ha.c:1805–1825) | Implemented |
| 6.2 | STANDBY + live primary denies votes | Drift: ha.c:1840 (uncommitted) now requires primary_ctrl_fd >= 0 && ha_heartbeat_is_fresh(ha). This matches the requirement text ("live primary connection") more literally than before, but test_standby_fresh_heartbeat_denies_vote_request (test_ha_vote_gate.c) was not updated and FAILS (fd defaults to −1 in ha_init) |
Partial — code OK per requirement, test broken |
| 6.3, 6.5 | HB timeout → ELECTING → new primary | heartbeat watchdog → ha_on_primary_ctrl_fd_rdhup (ha.c:3183) → ha_start_election_async; verified by test_ha_rdhup_integration.c (still registered, meson.build:120) |
Implemented |
| 6.4 | INIT/ELECTING evaluate term/voted_for/freshness gates | Production path: raft_on_vote_request (raft.c:106+). Drift: uncommitted raft.c change adds idempotent re-grant to the same candidate (raft.c step 4), while uncommitted ha.c change makes the legacy handle_request_vote candidate-agnostic deny (any second vote in term denied, ha.c:1851). The two vote paths now disagree; test_vote_granted_to_same_candidate_same_term (test_ha_election.c:432) FAILS |
Partial — semantic divergence + failing test |
| 7.1–7.3 | Winner pushes config + WAL; tag=0 standby never fetches | push_config_to_standbys + ha_send_ctrlhello_to_all_standbys (cluster.c:1376) in COMMITTING entry (ha.c:544–559); heartbeat-driven repl decision loop (HA_REPL_PUSH_CONFIG / WAL / snapshot, ha.c:1288+); fetch_config_from_primary removed from production (myapp.c:289) |
Implemented |
| 7.4 | Per-standby ack before counting toward majority | Evolved beyond the spec: per-standby standby_converged[] tracked from heartbeat stateReport replies; plus OPCode_replCatchUpDone → ha_repl_ack_received (ha.c:2485) |
Implemented |
| 8.1–8.3 | COMMITTING blocks jobs; majority ack → PRIMARY | gate case (ha.c:1436); ha_check_majority_convergence (ha.c:1244, primary mechanism) and ha_repl_ack_received (ha.c:2923, quorum −1 math) → ha_promote_to_primary |
Implemented |
| 8.4 | Configurable ack timeout → degraded open | committing_timeout_fn (ha.c:2875+) with converged/pending WARN detail; HA_DEFAULT_COMMITTING_TIMEOUT_MS = 60000 (ha.h:131) — design said 10000, requirement only demands "configurable" |
Implemented (design-doc drift only) |
| 9.1 | Single-node self-elect, no peer contact | raft_start_election n_peers==1 shortcircuit (raft.c:83); ctx freed inline (ha.c:3152–3157); zero-peer guard (ha.c:1981) |
Implemented |
| 9.2 | Winner never fetches config | Freshness gate + push model; no fetch call sites remain (stale comment at ha.c:3136 still mentions the removed fetch_config_from_primary) |
Implemented |
| 9.3 | RDHUP → re-election; promotion is hooks-only | ha_on_primary_ctrl_fd_rdhup + phase2_done skip; ha_promote_to_primary |
Implemented |
| 9.4 | Priority stagger | election_priority_step_ms stagger in myapp.c:225–231 and watchdog offset ha.c:1597; raft my_priority backoff (raft.c:273) |
Implemented |
| 9.* | "All existing behaviors preserved" | 2 unit tests fail in the working tree (see below); everything else green in the run performed for this audit | Partial |
Verified test failures (working tree, controller_unit_test, ASAN build)
test_vote_granted_to_same_candidate_same_term:FAIL: Expected 1 Was 0
test_standby_fresh_heartbeat_denies_vote_request:FAIL: Expected 0 Was 1 (Req 6.2)
Both are caused by the uncommitted handle_request_vote changes in ha.c:
- ha.c:1840 — deny now additionally requires
primary_ctrl_fd >= 0(test sets fresh heartbeat but fd = −1 fromha_init). - ha.c:1851 — duplicate-vote deny is now term-scoped and candidate-agnostic (denies same-candidate retransmission), whereas raft.c (the production vote path via cluster.c:373) deliberately keeps the idempotent same-candidate re-grant.
Additional drift findings (no test failure yet)
myapp_runskip-election "self" sentinel is ineffective in production (myapp.c, uncommitted): the checkstrcmp(voted_for_name, "self") != 0assumes self-votes are recorded as"self", butraft_cb_persist(ha.c:700–713) stores the node's real candidate name on a self-vote;"self"is only the test-mode/out-of-range placeholder. A restarted node that voted for itself would be classified "leader known" and skip its own election. Mitigated by the newly-armed heartbeat watchdog (it will fire and trigger a fresh election afterheartbeat_timeout_ms), so the cost is delayed availability, not a stuck node — but the sentinel should also compare againstc->myconf->name.- Stale comments: ha.c ~826–849 still describe per-election raft re-init ("overwritten by
ha_raft_reinit()called fromha_start_election_async") which the uncommittedha_init_raftrefactor removed; ha.c:3136 references the deletedfetch_config_from_primary. - No test coverage for
ha_init_raft: the new one-time-init invariant (raft state must survive across election cycles; double-init would reset term/voted_for) has no dedicated unit test; no testsuite file referencesha_init_raft. - Known dead code still compiled (carried from prior audits):
ha_wait_for_heartbeat_or_timeout(ha.c:1587, ha.h:551).
Implementation Approach Options
Since the spec is implemented, options address only the remaining gap closure.
Option A: Reconcile in place (extend existing) — Recommended
- Decide the intended duplicate-vote semantics once and align all three artifacts:
- Standard Raft (and raft.tla referenced in
src/controller/CLAUDE.md) permits idempotent re-grant to the same candidate; raft.c already implements this. Recommended: restore thestrcmp(..., candidate_id) != 0condition inhandle_request_vote(ha.c:1851) so the legacy/test path matches raft.c, keepingtest_vote_granted_to_same_candidate_same_termvalid.
- Standard Raft (and raft.tla referenced in
- Update
test_standby_fresh_heartbeat_denies_vote_requestto setha.primary_ctrl_fd = <valid fd>(the code change matches Req 6.2's "live primary connection" wording and should stay); add a sibling case asserting STANDBY + fresh heartbeat + fd = −1 → grant (no live connection). - Fix the myapp.c sentinel: treat
voted_for_name == c->myconf->namethe same as"self". - Clean stale comments (ha.c:826–849, ha.c:3136); optionally delete
ha_wait_for_heartbeat_or_timeout. - Add a small
ha_init_raftunit test (init once, run two election cycles, assert term monotonic / voted_for survives). - Trade-offs: ✅ smallest diff, keeps both vote paths consistent; ❌ keeps the duplicated legacy vote path alive.
Option B: Retire the legacy vote path (new/removed components)
- Delete
handle_request_votefrom ha.c entirely; port its tests to driveraft_on_vote_request+raft_cb_vote_eligible(the actual production gate via cluster.c). Single source of truth for vote semantics. - Trade-offs: ✅ eliminates the dual-path divergence class permanently; ❌ larger test-port effort (test_ha_election.c, test_ha_vote_gate.c re-plumb), M-size, riskier right before closing the hardening branch.
Option C: Hybrid
- Apply Option A now (unblock the test suite and the branch), file the Option B consolidation as a cleanup spec item alongside the existing dead-code list.
- Trade-offs: ✅ immediate green suite + tracked debt; ❌ divergence risk persists until the cleanup lands.
Effort and Risk
| Gap | Effort | Risk | Justification |
|---|---|---|---|
| Reconcile duplicate-vote semantics (ha.c ↔ raft.c) + fix test | S | Medium | One-line condition either way, but it is election-safety semantics — verify against raft.tla checklist before choosing |
Update Req 6.2 test for primary_ctrl_fd gate (+ new fd=−1 grant case) |
S | Low | Code already matches requirement text; test-only change |
| myapp.c "self" sentinel fix | S | Medium | Touches startup skip-election logic; needs a 3-node restart scenario to validate (deploy-ha) |
ha_init_raft unit test |
S | Low | Established Unity patterns in test_ha_election.c |
| Stale comments + dead-code removal | S | Low | Cosmetic; callers already verified absent |
| Option B legacy-path retirement (if chosen) | M | Medium | Test re-plumb across 2 suites; no production behavior change intended |
Recommendations / Next Steps
- Do not close the branch with the suite red. Fix the two failing tests via Option A (preferred) before committing the working-tree changes; the current diff would fail
/xce-build controller. - Preferred approach: Option C — Option A reconciliation now, Option B (single vote path) queued with the existing dead-code cleanup list.
- Keep:
ha_init_raftone-time init, theprimary_ctrl_fd >= 0deny refinement, the term-scopedvoted_forcheck in myapp.c, and the skip-election watchdog arming — all are correctness improvements consistent with the requirements. - Requirement-text note: Req 4.6's "heartbeat received while ELECTING → STANDBY" is satisfied by an equivalent mechanism (vote-grant → follower + primary ctrlhello retry). Record this as accepted drift rather than reworking the dispatch gate.
Research Needed
- Duplicate-vote semantics decision: confirm against
raft/raft.tla(Election Safety invariant) that same-candidate re-grant is safe in XCE's persistence model (vote persisted before grant → re-grant is replay-safe). Expected answer: yes; then align ha.c to raft.c. - "self" sentinel scenario: reproduce with deploy-ha — node self-votes (candidate), is killed mid-election, restarts within the same term; verify it does not skip its own election indefinitely.
- Full regression + ASAN pass: run
/xce-build controllerafter fixes; this audit ran onlycontroller_unit_test(2 failures, no ASAN errors observed in that run).
Gap Re-Audit (2026-06-18)
Post-implementation audit. Spec phase:
implemented(all tasks[x]). This pass re-verifies the 9 requirements against the CURRENT on-disk codebase, which has evolved substantially since the 2026-06-09 pass via four downstream specs that landed on this branch:ha-single-leader-role,ha-prevote,ha-convergence-recovery, andha-commit-durability. Evidence gathered via codegraph + grep (no build run this pass).
Headline finding
The async iomgr election mechanism this spec introduced is intact and in
production (Req 1–3, 6, 7, 9). However, the spec's COMMITTING availability-gate
model (Req 4.1, 5.2, 8.1–8.4) was deliberately superseded by ha-single-leader-role:
CTRL_PRIMARY_COMMITTINGenum value deleted (commit93dfc02). The enum inha.h:60–64now has onlyCTRL_PRIMARY / CTRL_STANDBY / CTRL_INIT / CTRL_ELECTING.- The dispatch-gate COMMITTING branch deleted (
71ce41f);ha_dispatch_gate(ha.c:2061) now has 4 role cases, no COMMITTING (ha.c:2067/2110/2127/2145). - The force-promotion watchdog + ack/convergence machinery deleted (
a5bf861):ha_repl_ack_received,ha_check_majority_convergence,committing_timeout_fnare all absent from the tree (grep: 0 matches). - Promotion now goes direct to CTRL_PRIMARY via a shared sequence
(
ha_promote_to_primary→ hooks + heartbeat + own-term WAL_NOOP + writable_lsn gate, ha.c:2298). The durability guarantee the COMMITTING gate was meant to provide is now enforced by the own-term commit gate (ha_arm_own_term_gate, ha.c:2259) and the durable-backed vote freshness floor — both owned byha-commit-durability. - Test artifact
test_ha_evaluate_committing.c(cited in earlier audits) is gone.
This is accepted, intentional drift, not a regression: a later approved spec
replaced a weaker availability gate with a stronger durability invariant. The
requirement intent (a promoted primary holds current state and never serves stale
data) is better satisfied now. The requirement wording (specific
CTRL_PRIMARY_COMMITTING state + majority-ack counter) is obsolete.
Status of the two failures flagged on 2026-06-09
Both prior-audit failures appear resolved in the current tree (Option A reconciliation was applied):
primary_ctrl_fd >= 0deny gate is now intentional production code (handle_request_vote, ha.c:320:role == CTRL_STANDBY && ha->primary_ctrl_fd >= 0 && ha_heartbeat_is_fresh(ha)), with an explicit comment block (ha.c:312–319) justifying it against the RDHUP / liveness-watchdog case. Matches Req 6.2's "live primary connection" wording.myapp_run"self" sentinel now also compares the node's real name (myapp.c:281–283:voted_for_name != "" && != "self" && != myconf->name), closing the self-vote-skips-own-election hole the prior audit raised.
Caveat: not re-run this pass (no build). If closing the branch, run
/xce-build controllerto confirmcontroller_unit_testis green.
Requirement-to-Asset Map (current code, tagged)
| Req | Summary | Current Asset (file:line) | Tag |
|---|---|---|---|
| 1.1–1.3 | No blocking vote pthreads; vote via standard I/O; probe timeout = deny | ha_start_election_async (ha.c:4400) → raft_start_election (raft.c:61); timeout via raft_election_timeout_fn (ha.c:342) + delay_task_ms; vote_request_thread/am_i_primary_quorum removed |
MET |
| 2.1–2.4 | Async TCP+TLS via iomgr; TLS client ctx; vote after handshake; connect fail = deny | endpoint_set_on_connected (netio.c) fire-once; on_vote_connected → xce_request; client_ssl_ctx in ha_state_t; vote_ep_err_handler (ha.c:3937) counts denied vote |
MET |
| 3.1–3.4 | Reply via iomgr dispatch; no capnp UAF; field extraction; term fencing | peer_ctrl_msg_handler (ha.c:3600) role-routes; handle_request_vote_reply (ha.c:3017) reads fields before free; term fencing in raft_on_vote_reply (raft.c:338) |
MET |
| 4.1 | Quorum → CTRL_PRIMARY_COMMITTING (pre-availability) | SUPERSEDED: quorum → direct CTRL_PRIMARY via raft_cb_become_leader / ha_promote_to_primary (ha.c:2298); COMMITTING state deleted |
Constraint (intentional drift — ha-single-leader-role) |
| 4.2–4.3 | Freshness gate = ground truth; cold-start only highest config_tag wins | raft_cb_vote_eligible → ha_vote_self_freshness (ha.c:2550) per-field max(local, learned-committed); durable-backed floor via ha_candidate_advertise_lsn/persist_durable_floor (ha.c:2587) [ha-commit-durability 4.2]; config-less node refuses to campaign |
MET (strengthened) |
| 4.4–4.5, 4.7 | Split-vote / no-reply retry; retry indefinitely | raft_on_election_timeout (raft.c:399), raft_on_leader_lost (raft.c:417) priority-scaled backoff; heartbeat watchdog re-arm |
MET |
| 4.6 | ELECTING + heartbeat from new PRIMARY → STANDBY | Equivalent mechanism (vote-grant → raft_cb_become_follower; winner ctrlhello retried), not literal heartbeat→STANDBY. Documented accepted drift since 2026-06-09 |
MET (mechanism drift, documented) |
| 5.1 | ELECTING gate: requestVote/probeConfigTag only, else NOT_READY | ha_dispatch_gate CTRL_ELECTING case (ha.c:2127); PreVote opcodes added by ha-prevote |
MET |
| 5.2 | COMMITTING: repl acks only | SUPERSEDED: no COMMITTING state/gate; promotion is atomic to PRIMARY. Durability via own-term gate instead | Constraint (intentional drift) |
| 5.3–5.4 | PRIMARY full traffic; STANDBY repl/heartbeat | ha_dispatch_gate CTRL_PRIMARY (ha.c:2067) / CTRL_STANDBY (ha.c:2145) |
MET |
| 5.5 | Monotonic persisted term across restart | ha_persist_term/ha_restore_term; raft_cb_persist persists before grant; ha_init_raft (ha.c:3991) seeds raft from restored term once at startup (myapp.c:242) |
MET |
| 6.1 | PRIMARY denies all votes | raft.c role gate + legacy handle_request_vote gate (COMMITTING clause now dead/removed — only PRIMARY remains) |
MET |
| 6.2 | STANDBY + live primary denies votes | handle_request_vote (ha.c:320): CTRL_STANDBY && primary_ctrl_fd >= 0 && ha_heartbeat_is_fresh. Prior failing test reconciled |
MET |
| 6.3, 6.5 | HB timeout → ELECTING → new primary | heartbeat watchdog → ha_on_primary_ctrl_fd_rdhup → ha_start_election_async; test_ha_rdhup_integration.c registered (meson.build:123) |
MET |
| 6.4 | INIT/ELECTING evaluate term/voted_for/freshness | raft_on_vote_request (raft.c:110); dual-path divergence flagged 2026-06-09 — reconcile/retire still tracked (see residual gaps) |
MET (with residual dual-path debt) |
| 7.1–7.3 | Winner pushes config + WAL; tag=0 standby never fetches | push_config_to_standbys + ha_send_ctrlhello_to_all_standbys (cluster.c:1629); heartbeat-driven repl decision loop; fetch_config_from_primary removed |
MET |
| 7.4 | Per-standby ack before counting toward majority | Re-homed: durable-commit majority via commit engine (commit_on_ack/commit_advance_locked, commit.c) + standby_converged[]; the spec's repl_ack_count counter deleted |
MET (re-homed to commit engine) |
| 8.1–8.3 | COMMITTING blocks jobs; majority ack → PRIMARY; PRIMARY opens traffic | SUPERSEDED: no COMMITTING. Job traffic gated by ha_dispatch_gate PRIMARY + own-term writable_lsn gate (commit.c:383) — WAIT completion withheld until committed_lsn >= writable_lsn. Stronger than the original ack-count gate |
Constraint (intentional drift) |
| 8.4 | Configurable ack timeout → degraded open | SUPERSEDED: committing timeout deleted; degraded-mode handled by ha-commit-durability's non-promotable fence + bounded catch-up (commit 59d9df2/addf53e) | Constraint (intentional drift) |
| 9.1 | Single-node self-elect, no peer contact | raft_start_election n_peers==1 shortcircuit (raft.c:87) |
MET |
| 9.2 | Winner never fetches config | Freshness gate + push model; no fetch call sites | MET |
| 9.3 | RDHUP → re-election; promotion hooks-only | ha_on_primary_ctrl_fd_rdhup + phase2_done skip; ha_promote_to_primary shared sequence |
MET |
| 9.4 | Priority stagger | election_priority_step_ms stagger (myapp.c); raft my_priority backoff (raft.c:421) |
MET |
Tally: 9/9 requirements satisfied in intent. 6 of 9 requirement groups fully MET as written; 3 groups (4.1, 5.2, 8.1–8.4) carry Constraint tags — their literal wording is obsolete because the COMMITTING availability gate was intentionally replaced by a stronger durability gate in a later approved spec.
Residual / deferred gaps
| # | Gap | Type | Notes |
|---|---|---|---|
| R1 | Requirements text still describes CTRL_PRIMARY_COMMITTING + majority-ack gate (Req 4.1, 5.2, 8.1–8.4) that no longer exist |
Doc drift | requirements.md/design.md are now historically inaccurate vs. code. Spec is closed; this is documentation debt, not a code gap. |
| R2 | Dual vote-path divergence (handle_request_vote in ha.c vs raft_on_vote_request in raft.c) |
Constraint / tech debt | Carried from 2026-06-09. Production vote path is raft.c (via cluster.c); ha.c legacy path retained for tests. Option B (retire legacy path) still open. |
| R3 | ha_init_raft one-time-init invariant has no dedicated unit test |
Missing (test) | grep: no testsuite file drives ha_init_raft across two election cycles. Double-init would reset term/voted_for. |
| R4 | Known dead code still compiled: ha_wait_for_heartbeat_or_timeout, am_i_primary, heartbeat-listener stubs; stale comments referencing deleted fetch_config_from_primary (ha.c) |
Constraint (cosmetic) | Carried across every prior audit; no correctness impact. |
| R5 | Build/ASAN not re-run this pass | Unknown | Audit was code-graph only. Confirm controller_unit_test green before relying on R1/the 2 reconciled tests. |
Open-work options (only R1–R3 warrant action; R4 cosmetic, R5 verification)
R1 — reconcile requirements text with superseded gate
- Option A: Add a "Superseded by ha-single-leader-role / ha-commit-durability" banner to requirements.md §4/§5/§8 (Effort S, Risk Low). Recommended.
- Option B: Rewrite Req 4/5/8 to describe the own-term gate model (Effort M, Risk Low) — higher fidelity but rewrites an already-closed spec.
- Option C: Leave as-is, rely on this research.md note (Effort 0, Risk Low) — readers of requirements.md alone stay misled.
R2 — single vote-path consolidation (from 2026-06-09 Option B)
- Option A: Keep dual path, document raft.c as authoritative (Effort S, Risk Low).
- Option B: Delete
handle_request_votefrom ha.c, re-plumb its tests ontoraft_on_vote_request(Effort M, Risk Medium — election-safety semantics; verify against raft.tla Election Safety). - Option C: Hybrid — A now, B as a tracked cleanup-spec item. Recommended.
R3 — ha_init_raft invariant test
- Option A: Add a unit test (init once, run two election cycles, assert term monotonic
- voted_for survives) using existing Unity patterns in test_ha_election.c (Effort S, Risk Low). Recommended.
| Open item | Effort | Risk |
|---|---|---|
| R1 doc banner (Option A) | S | Low |
| R2 vote-path consolidation (Option C) | S now / M later | Medium (semantics) |
| R3 ha_init_raft test | S | Low |
| R4 dead-code/stale-comment cleanup | S | Low |
| R5 build + ASAN verification | S | Low |
Conclusion
election-iomgr-refactor is CLOSED as an implementation: the async iomgr
election it delivered is live and all 9 requirements are satisfied in intent. The
only true open items are documentation/test debt (R1, R3) and pre-existing tech debt
(R2, R4) — none block the feature. The single material caveat is that the
availability-gate requirements (4.1/5.2/8.x) are now historically inaccurate because
a later approved spec replaced the COMMITTING gate with a stronger durability gate;
this should be recorded in requirements.md (R1) so the spec docs stop describing
removed code.