Appended "Gap Analysis — Validation Pass (2026-06-09)" to each spec's research.md with requirement-to-asset maps, status tags, and effort/risk. Key outcomes: - ha-repl-hardening: REOPENED with task series 7.1-7.7 — replication payload data plane is missing (snapshot chunks and WAL catch-up ship header-only; standbys converge on LSN without holding entity state) - worker-join-refactor, resval-id-decoupling: marked implemented (all criteria re-verified, full test suite green) - buglog: 335/336 (scheduler dequeue, open), 337/338/339 (fixed in previous commit); cerebrum Decision Log corrected (term-only vote deny was recorded as intentional but was a regression)
35 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).