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.
34 KiB
Research & Design Decisions: ha-peer-connection-reuse
Discovery Scope
Type: Extension — modifies the existing election endpoint lifecycle in ha.c and standby connection model in cluster.c.
Discovery method: Light discovery — codebase analysis of existing patterns.
Key Investigations
1. Existing msg_handler Reassignment Pattern
Finding: on_vote_connected (ha.c:1869) already performs ep->msg_handler = vote_reply_msg_handler without a lock. This is safe because iomgr is single-threaded and msg_handler is only written from the iomgr thread.
Implication: A state-aware dispatcher (peer_ctrl_msg_handler) that reads ha->role atomically can replace this explicit reassignment without any iomgr changes. The role is already atomic (_Atomic int32_t role).
2. vote_ep_ctx_t Lifetime
Finding: vctx (vote_ep_ctx_t) lives in ep->data. It is currently freed by ha_election_cleanup. The vote_ep_err_handler intentionally does NOT free it because vote_reply_msg_handler also references ep->data.
Implication: With connection reuse, vctx outlives the election for promoted connections. Lifetime is extended: freed only when the promoted endpoint closes (via err_handler) or when election cleanup runs for the loser path.
3. ha->standby_endpoints Population
Finding: Currently populated in cluster.c when standbys create inbound connections to the primary and register with HostRole_controller. With connection reuse, the primary populates it directly from promoted outgoing vote endpoints in raft_cb_become_leader.
Implication: The existing ha_send_ctrlhello_to_all_standbys and push_config_to_standbys iterate ha->standby_endpoints. Populating it from promoted connections means no changes are needed to these functions — they work on whatever is in the array.
4. Standby Side: fetch_config_from_primary
Finding: raft_cb_become_follower (ha.c:457-462) schedules fetch_config_from_primary via delay_task_ms with 100ms delay. This is the tier-1/2/3 discovery path the spec wants to eliminate.
Implication: Remove this call. The primary pushes config via promoted connections. The standby receives it passively on the inbound connection (handled by existing vtaskmgr path).
5. Heartbeat Watchdog Arming
Finding: ha_arm_heartbeat_timeout_vtask is called after ha_find_and_connect_to_primary succeeds (production path). With connection reuse, ha_find_and_connect_to_primary is not called on the standby path.
Implication: Arm the watchdog in raft_cb_become_follower instead, immediately when transitioning to CTRL_STANDBY. The ha_on_heartbeat_received call from ctrlhello/heartbeat handlers resets the deadline.
6. Primary Control Channel (primary_ctrl_fd) on Standby
Finding: ha->primary_ctrl_fd is set by ha_find_and_connect_to_primary. The RDHUP and heartbeat watchdog use it as the anchor for failover. With connection reuse, the primary's inbound connection (accepted from winner's outgoing vote connection) is the primary control channel.
Implication: Set ha->primary_ctrl_fd = ep->fd in handle_ctrlhello when ctrlhello arrives and role == CTRL_STANDBY. The existing controller leave handler in cluster.c checks ep->fd == ha->primary_ctrl_fd to trigger re-election.
7. Promoted Connection Leave Handling
Finding: The cluster.c controller leave handler removes endpoints from ha->standby_endpoints. This fires for inbound connections by checking priv->role == HostRole_controller. Promoted outgoing vote endpoints have ep->data = vctx (vote_ep_ctx_t), not a role struct, so the existing leave handler cannot identify them.
Decision: Handle promoted endpoint disconnect in vote_ep_err_handler instead of the cluster.c leave handler. When role is COMMITTING/PRIMARY, the err_handler removes the endpoint from ha->standby_endpoints.
Architecture Decisions
Decision 1: State-Aware Dispatcher vs. Explicit Reassignment
Chosen: State-aware dispatcher (peer_ctrl_msg_handler) that reads ha->role atomically.
Why not explicit reassignment: Explicit reassignment in raft_cb_become_leader (which runs in the raft callback chain, still in iomgr thread) would be safe, but requires coordinating "which endpoints to reassign" and creates a narrow window where a message could arrive between role update and handler swap. The state-aware dispatcher eliminates this concern entirely.
Boundary: peer_ctrl_msg_handler is set once at endpoint creation and never changed.
Decision 2: No Changes to iomgr
Chosen: Zero changes to src/lib/netio.c or src/include/netio.h.
Why: endpoint_t already supports msg_handler reassignment via endpoint_set_msg_handler. The state-aware dispatcher approach doesn't reassign at all — it reads state at dispatch time. No iomgr API changes are needed.
Decision 3: vote_ep_ctx_t Reuse for Promoted Connections
Chosen: Keep vote_ep_ctx_t as ep->data for promoted connections. No separate replication context struct.
Why: vote_ep_ctx_t already carries ha, c, and peer_idx — exactly what replication ACK handling needs. Adding a promoted flag (or reading role) is sufficient to differentiate phases. No new struct reduces code churn.
Decision 4: ha_vote_entry_t.ep for Endpoint Tracking
Chosen: Add endpoint_t *ep to ha_vote_entry_t to track the outgoing vote endpoint per peer.
Why: raft_cb_become_leader and raft_cb_become_follower receive only ha_state_t*. They read ha->elect_ctx to get ha_elect_ctx_t* and iterate votes[] to find endpoints. This is the cleanest way to pass endpoints from raft_cb_send_vote_request to the callbacks without additional global state.
Synthesis Outcomes
Generalization: The "peer control channel" concept generalizes both the vote phase and the replication phase. One endpoint, two roles, one dispatcher. The role is the state machine.
Build vs. Adopt: All changes are purely additive/modificative within existing ha.c/cluster.c patterns. No new libraries.
Simplification: Removing fetch_config_from_primary from the standby path eliminates the tier-1/2/3 discovery complexity. The standby path becomes purely passive.
Post-Implementation Gap Analysis (2026-05-31)
Implementation Status
Tasks 1–6 (all core implementation) are complete. The following was verified in the live codebase:
| Component | Status | Evidence |
|---|---|---|
ha_vote_entry_t.ep field in ha.h |
✅ Done | ha.h:82 |
peer_ctrl_msg_handler (state-aware dispatcher) |
✅ Done | ha.c:2082 |
on_vote_connected uses peer_ctrl_msg_handler |
✅ Done | ha.c:1905 |
raft_cb_become_leader promotes vote eps to standby_endpoints |
✅ Done | ha.c:353–430 |
raft_cb_become_follower closes vote eps + arms watchdog |
✅ Done | ha.c:440–496 |
handle_ctrlhello sets primary_ctrl_fd (config_repl.c) |
✅ Done | config_repl.c:1596–1603 |
vote_ep_err_handler removes ep from standby_endpoints |
✅ Done | ha.c:2437–2442 |
Test file test_ha_connection_reuse.c (8 unit tests) |
✅ Done | testsuite/ |
Test file wired into meson.build |
✅ Done | meson.build:120 |
Gap 1 (CRITICAL): fetch_config_from_primary Still Present in ha_elect_evaluate_and_signal
Location: src/controller/ha.c, lines 1737–1743
/* ha_elect_evaluate_and_signal — STANDBY result path (old election path) */
#ifndef XCE_TESTING
if (c != NULL) {
extern int fetch_config_from_primary(cluster_t *c);
delay_task_ms(&c->delaymgr, 100, 1,
(delay_task_entry_fn_t)fetch_config_from_primary, c);
}
#endif
Analysis: There are two code paths that can transition a node to CTRL_STANDBY:
raft_cb_become_follower(raft library callback, ha.c:440–496) — the new path introduced by the election-iomgr-refactor spec. This path correctly does NOT callfetch_config_from_primary. ✅ha_elect_evaluate_and_signal(ha.c:~1702–1744) — the older custom election path (invoked directly byhandle_request_vote_reply/ vote-thread path). This path still callsfetch_config_from_primary. ❌
Requirement violated: Req 2.1 ("CTRL_STANDBY does not initiate outbound TCP connections") and Req 2.4 ("CTRL_STANDBY does not invoke tier-1/2/3 discovery").
Risk: Whether this matters depends on which path is actually active at runtime. If the system now exclusively uses the raft library callback path (and ha_elect_evaluate_and_signal is never called with a STANDBY result), the bug is dormant. But the old path still exists in the code and can be triggered by direct vote-reply handling, so it is a latent correctness issue and contradicts the spec intent.
Fix: Remove the #ifndef XCE_TESTING block at ha.c:1737–1743 (the fetch_config_from_primary call) from ha_elect_evaluate_and_signal. Matches task 4.1 intent, which was scoped only to raft_cb_become_follower.
Gap 2 (LOW): ha_arm_heartbeat_timeout_vtask Not Called from ha_elect_evaluate_and_signal
Analysis: Similarly to Gap 1, ha_elect_evaluate_and_signal does not call ha_arm_heartbeat_timeout_vtask in its STANDBY result path, while raft_cb_become_follower does (ha.c:495). If the old path is still reachable, the heartbeat watchdog is not armed from that path.
Requirement potentially violated: Req 2.3 and Req 3.3.
Fix: Mirror the watchdog arm from raft_cb_become_follower into ha_elect_evaluate_and_signal's STANDBY path, or confirm the old path is dead code and remove it.
Gap 3 (MEDIUM): Unit Tests Written But Not Verified Passing
Location: src/controller/testsuite/test_ha_connection_reuse.c
Analysis: The test file exists with 8 unit tests (task 7.1) and is wired into meson.build. However, task 7.1 is marked open in tasks.md. The tests reference several extern symbols that must be defined under XCE_TESTING in ha.c:
peer_ctrl_msg_handler_for_test— present (ha.c:2325)vote_ep_err_handler_for_test— present (ha.c:2316)vote_reply_build_pkt_for_test— present (ha.c:2123)repl_catchup_done_pkt_for_test— present (ha.c:2282)vote_ep_ctx_build_for_test— present (ha.c:2210)raft_cb_become_leader_for_test— present (ha.c:2336)raft_cb_become_follower_for_test— present (ha.c:2343)ha_push_config_hook,ha_send_ctrlhello_hook— present (ha.c:1523, 1530)
All seams appear to be in place. The tests should pass, but a build run (/xce-build controller) is needed to confirm.
Gap 4 (LOW): Integration Tests Not Written (Tasks 7.2, 7.3)
Tasks open:
- 7.2: Two-node integration test verifying standby never calls
ha_find_and_connect_to_primary - 7.3: Integration test confirming WAL replication ACK on promoted ep increments
repl_ack_count
Risk: Without integration tests, the full end-to-end behaviour (standby passive model, WAL replication via promoted connection) is unverified at the system level. The unit tests cover the individual components but not their interaction with the running cluster state machine.
Requirements Feasibility Assessment
| Req | Summary | Status | Gap |
|---|---|---|---|
| 1.1 | Candidate establishes outbound TLS per peer | ✅ existing path unchanged | None |
| 1.2 | Vote connection reused as peer channel | ✅ peer_ctrl_msg_handler implemented |
None |
| 1.3 | Winner promotes to standby_endpoints |
✅ raft_cb_become_leader extended |
None |
| 1.4 | Loser closes vote endpoints | ✅ raft_cb_become_follower closes |
None |
| 2.1 | CTRL_STANDBY no outbound TCP | ⚠️ partially | Old path still calls fetch_config_from_primary |
| 2.2 | Standby accepts ctrlhello/config/WAL passively | ✅ existing path unchanged | None |
| 2.3 | No heartbeat → new election | ✅ watchdog armed in new path | Old path unarmed |
| 2.4 | No tier-1/2/3 discovery | ⚠️ partially | Old path still schedules it |
| 3.1 | ctrlhello → designate primary control channel | ✅ handle_ctrlhello updated |
None |
| 3.2 | RDHUP detected in one epoll cycle | ✅ existing mechanism | None |
| 3.3 | Watchdog arm semantics preserved | ✅ new path | Old path unarmed |
| 4.1 | Loser closes all outbound vote eps | ✅ raft_cb_become_follower |
None |
| 4.2 | Failed connect = denied vote, no reconnect | ✅ vote_ep_err_handler unchanged |
None |
| 4.3 | New election = fresh connections | ✅ ha_start_election_async unchanged |
None |
| 5.1–5.4 | No regression in HA behaviors | ✅ promoted path uses existing iterators | Integration tests needed |
| 5.5 | No new ASAN errors | Unknown | Needs build + test run |
Implementation Approach Summary
Chosen approach: Option A (extend existing components) — completed for tasks 1–6.
Remaining work for full compliance:
- (Critical — Req 2.1, 2.4) Remove
fetch_config_from_primarycall fromha_elect_evaluate_and_signalSTANDBY path (ha.c:1737–1743), or confirm the function is never reached on the STANDBY branch in production. Mirrorha_arm_heartbeat_timeout_vtaskcall if the path is still live. - (Verification — task 7.1) Run
/xce-build controllerto confirm all unit tests intest_ha_connection_reuse.cpass and ASAN is clean. - (Integration — task 7.2) Add two-node test verifying standby does not call
ha_find_and_connect_to_primary. - (Integration — task 7.3) Add promoted-connection WAL ACK test.
Effort and Risk
| Item | Effort | Risk |
|---|---|---|
Remove fetch_config_from_primary from old path |
S (1 hour) | Low — isolated removal; old path analysis needed first |
| Verify unit tests pass | S (build + inspect) | Low — seams all present |
| Task 7.2 integration test | M (1–2 days) | Medium — requires two-node test harness plumbing |
| Task 7.3 integration test | M (1–2 days) | Medium — requires promoted ep + WAL harness |
Overall: The core feature is implemented. The remaining work is test validation + one latent correctness issue in the old election path.
Cross-Spec Gap Audit (2026-06-08)
Batch
/kiro-validate-gapacross 9 specs on persistent_ha branch.
Analysis Summary
- ha-peer-connection-reuse is ~98% complete. All 7 task groups (1–7) are
[x]in tasks.md. - Prior CRITICAL gap RESOLVED: The
fetch_config_from_primarycall inha_elect_evaluate_and_signal's STANDBY path was removed (confirmed: only comments remain in ha.c). The standby is fully passive — no outbound TCP connections fromCTRL_STANDBY. - Integration tests (7.2, 7.3): Both marked
[x]. The two-node integration test and WAL ACK test are done. - Remaining: Build verification (
/xce-build controller) to confirm all unit tests intest_ha_connection_reuse.cpass under ASAN. - Cross-spec relationships:
ha-repl-hardeningcommit0762577(task 5.1: "reduce handle_ctrlhello to connection setup only") directly depends on this spec'speer_ctrl_msg_handler+ promoted connection model. The two specs are consistent — ha-repl-hardening moves all replication decisions to the heartbeat decision loop, which works over the promoted connections established by this spec.
Gap Analysis — Validation Pass (2026-06-09)
Scope: requirements.md (Reqs 1–5) vs working tree on
persistent_ha(including uncommitted changes toha.c,ha.h,myapp.c,raft.c). All tasks in tasks.md are checked[x]. This pass re-verifies each acceptance criterion against on-disk code and a live run of the controller unit-test binary.
1. Current State Summary
- All seven task groups are implemented in
src/controller/ha.c,src/controller/ha.h,src/controller/config_repl.c, with tests insrc/controller/testsuite/test_ha_connection_reuse.c(registered insrc/controller/meson.build:119). - The dispatcher (
peer_ctrl_msg_handler, ha.c:2570) is set once inon_vote_connected(ha.c:2321, 3586) and routes byha->role: ELECTING →vote_reply_decode_and_dispatch(ha.c:2370), COMMITTING/PRIMARY →repl_ack_decode_and_dispatch(ha.c:2456), other → WARN + drop (ha.c:2589). - Winner promotion:
raft_cb_become_leader(ha.c:513–529) appends every non-NULLvotes[i].eptoha->standby_endpointsunderha->lock, then pushes config (ha.c:544) and ctrlhello (ha.c:559) over those endpoints. - Loser path:
raft_cb_become_follower(ha.c:640–664) callsendpoint_finion each vote ep, setsCTRL_STANDBY, then armsha_arm_heartbeat_timeout_vtask— no outbound activity afterward. - Standby side:
handle_ctrlhello(config_repl.c:1357–1374) setsprimary_ctrl_fd = ep->fd(idempotent== -1guard) and resets the liveness watchdog viaha_on_heartbeat_received. - The tier-1/2/3 discovery path is fully removed:
ha_find_and_connect_to_primaryandfetch_config_from_primaryno longer exist as functions (only stale comment references remain — ha.h:741, ha.c:3136–3137, cluster.c:1006, main.c:168). myapp.c:289 documents the new passive-standby contract. - Implementation went beyond the spec in one compliant way: late joiners are reconnected by the primary heartbeat task (
primary_heartbeat_fn/on_standby_connected, ha.c:85–86) via fresh outbound connections — this respects Req 4.2's "in that election round" restriction. - Live test run (controller_unit_test built 2026-06-09 13:07, newer than all sources): 769 PASS, 0 FAIL; all 10 connection-reuse tests pass. However ASAN reports 37,360 bytes leaked in 397 allocations at process exit (see Req 5.5 below).
2. Requirement-to-Asset Map
| Req | Summary | Code Assets | Status |
|---|---|---|---|
| 1.1 | One outbound TLS connection per peer for vote RPC | raft_cb_send_vote_request ha.c:~147–190; SSL via ha->client_ssl_ctx |
Implemented (Constraint: TLS only when client_ssl_ctx is configured; plaintext fallback otherwise) |
| 1.2 | Reuse surviving vote connection as persistent peer channel | peer_ctrl_msg_handler ha.c:2570; set once at ha.c:2321/3586, never reassigned |
Implemented — test test_dispatcher_* x3 PASS |
| 1.3 | Winner promotes vote connections to standby channels | raft_cb_become_leader promotion loop ha.c:513–529 |
Implemented — test_become_leader_promotes_vote_endpoints, ..._skips_null_endpoints PASS |
| 1.4 | Loser closes all outbound vote connections | raft_cb_become_follower close loop ha.c:640–655 |
Implemented — test_become_follower_closes_vote_endpoints PASS |
| 2.1 | Standby initiates no outbound TCP to peers | fetch_config_from_primary deleted; passive wait myapp.c:288–300 |
Implemented — test_follower_passive_no_outbound_connect PASS |
| 2.2 | Standby processes ctrlhello/config/WAL on inbound connection | cluster.c:246 dispatch → handle_ctrlhello; existing config/WAL handlers unchanged |
Implemented |
| 2.3 | No inbound comm within timeout → new election | ha_arm_heartbeat_timeout_vtask ha.c:3408 armed at ha.c:664 and myapp.c skip-election path; fires ha_on_primary_ctrl_fd_rdhup → CTRL_ELECTING |
Implemented (Note: comment at ha.c:662–663 says "election_timeout_ms"; actual watchdog uses heartbeat_timeout_ms — stale comment) |
| 2.4 | No tier-1/2/3 discovery on standby | ha_find_and_connect_to_primary removed entirely (exceeds design, which said it would "remain for future use") |
Implemented |
| 3.1 | First ctrlhello designates primary control channel + arms watchdog | config_repl.c:1357–1374, idempotency guard primary_ctrl_fd == -1 |
Implemented — test_handle_ctrlhello_sets_primary_ctrl_fd PASS |
| 3.2 | RDHUP detected within one epoll cycle → CTRL_ELECTING | ha_on_primary_ctrl_fd_rdhup ha.c:3183–3199; iomgr err-handler path unchanged |
Implemented |
| 3.3 | Watchdog same timeout semantics as primary_ctrl_fd |
Same ha_arm_heartbeat_timeout_vtask vtask, heartbeat_timeout_ms check ha.c:3302+ |
Implemented |
| 4.1 | Explicit close of vote endpoints on loss | endpoint_fini + NULL-out, ha.c:648–652 |
Implemented |
| 4.2 | Failed vote connection → denied vote, no reconnect that round | vote_ep_err_handler ha.c:2942: ELECTING → handle_request_vote_reply(grant=0); COMMITTING/PRIMARY → remove ep from standby_endpoints under lock |
Implemented — test_err_handler_removes_promoted_ep_from_standby PASS |
| 4.3 | New election → fresh outbound connections | ha_start_election_async allocates fresh ha_elect_ctx_t per round; eps created in raft_cb_send_vote_request |
Implemented |
| 5.1 | Config push via promoted connections | ha.c:531–553 push_config_to_standbys over populated standby_endpoints |
Implemented |
| 5.2 | WAL replication via promoted connections | ctrlhello → catch-up over promoted eps; repl_ack_decode_and_dispatch → ha_repl_ack_received |
Implemented — test_promoted_ep_repl_ack_promotes_to_primary PASS |
| 5.3 | Single-node self-elect, no peer connections | raft library shortcircuit (unchanged); ectx/NULL guards in both callbacks |
Implemented |
| 5.4 | Committing timeout → degraded PRIMARY | committing_timeout_fn armed ha.c:567–591; cancel-on-stepdown ha.c:611–621 |
Implemented |
| 5.5 | No new ASAN errors vs baseline | Live run: 769 PASS / 0 FAIL, but ASAN leak report at exit: 37,360 B / 397 allocs. Traces include this spec's test file (fixture callocs at test_ha_connection_reuse.c:334–335, 470–471 — test-only) AND one production allocation: ha_arm_heartbeat_timeout_vtask ctx (ha.c:3424) leaked when invoked via handle_ctrlhello (config_repl.c:1374) in the test harness (no live delaymgr to consume/free the ctx) |
Partial — leaks exist; whether they are "new vs baseline" is unverified (Unknown) |
Score: 18 of 19 acceptance criteria Implemented; 1 Partial (5.5).
Documentation/metadata drift (not functional gaps)
- ha.c:3136–3137 comment still says "myapp_run schedules fetch_config_from_primary" — the function no longer exists.
- ha.h:741 comment references removed
ha_find_and_connect_to_primary. - ha.c:662 watchdog comment says
election_timeout_ms; code usesheartbeat_timeout_ms. - design.md places
handle_ctrlhelloincluster.c; the actual handler lives inconfig_repl.c:1357(tasks.md is correct). - design.md says
ha_find_and_connect_to_primary"remains for future use"; it was removed entirely (the stronger guarantee — acceptable, but design should be noted as superseded). - spec.json
phaseis still"tasks-generated"although all tasks are[x]and tests pass. - Working tree carries uncommitted changes (
ha_init_raftextraction, vote-gate now requiresprimary_ctrl_fd >= 0for the fresh-heartbeat deny, raft.c changes). These interact with this spec's assets and pass tests, but are not committed — provenance vs adjacent specs (ha-repl-hardening / ha-config-bootstrap) is unclear.
3. Implementation Approach Options (for the remaining gap)
Option A: Extend Existing (close Req 5.5 + comment hygiene in place) — RECOMMENDED
- Free test fixtures in
test_ha_connection_reuse.c(callocs at lines 334–335, 470–471, and ctrlhello test path) at test teardown. - For the
ha_arm_heartbeat_timeout_vtaskctx leak: either free the armed ctx in the test teardown via the existing cancel flag, or gate the arm behind the existingc == NULLtest seam inhandle_ctrlhello's call. - Fix the four stale comments; update spec.json phase.
- ✅ Smallest diff, no interface change, all in files this spec already owns. ❌ Leaves "baseline comparison" implicit — pair with the Research item below.
Option B: New Components (ASAN baseline harness)
- Add a
tools/asan-baseline.shthat runs the controller suite on a pinned baseline commit and diffs leak reports, making Req 5.5 mechanically checkable for every spec. - ✅ Permanent, reusable verification for all HA specs' "no new ASAN errors" criteria. ❌ New tooling outside this spec's boundary; more effort than the gap warrants on its own.
Option C: Hybrid
- Do Option A now (fix the known leaks regardless of baseline status), and file Option B as a follow-up task under a tooling/steering spec.
- ✅ Closes 5.5 definitively (zero leaks ⇒ trivially "no new errors") while seeding durable verification. ❌ Slightly more coordination.
4. Effort & Risk
| Item | Effort | Risk | Justification |
|---|---|---|---|
| Test-fixture leak frees (test file only) | S | Low | Mechanical free()/teardown additions in one test file |
| Watchdog-ctx leak under test harness | S | Low | Existing cancel-flag/test-seam patterns apply; production path (delaymgr consumes ctx) unaffected |
| Stale comment + design.md/spec.json corrections | S | Low | Documentation-only |
| Commit hygiene for uncommitted ha.c/ha.h/myapp.c/raft.c changes | S | Medium | Changes span two specs' boundaries; mis-attribution complicates future validation passes |
| Option B baseline-diff tooling (optional) | M | Low | New script + CI wiring, no production code |
Overall remaining effort: S. Overall risk: Low.
5. Recommendations for Design/Closure
- Adopt Option A (or C): eliminate the reported leaks rather than litigating the baseline — a leak-free run satisfies Req 5.5 unconditionally.
- Verify the production-side lifetime of the
ha_arm_heartbeat_timeout_vtaskctx whenhandle_ctrlhellore-arms repeatedly (cancel-flag of the previous ctx must lead to a free in the delaymgr callback; confirm no slow accumulation on long-lived standbys). - Update spec.json to an implementation-complete phase and annotate design.md with the two superseded statements (handle_ctrlhello location; full removal of
ha_find_and_connect_to_primary). - Commit or re-home the uncommitted working-tree changes with spec attribution before the next validation pass.
Research Needed
- ASAN baseline diff: run the controller suite at the pre-feature commit to confirm which of the 397 leaked allocations predate this spec (only needed if Option A is rejected).
- Watchdog ctx lifetime in production: trace
heartbeat_timeout_ctxownership through cancel/re-arm cycles on a long-lived standby (ha.c:3337–3449). - Uncommitted raft.c changes: determine which spec owns the
ha_init_raftextraction and theprimary_ctrl_fd >= 0vote-gate tightening (interacts with Req 3.1's designation semantics), and validate against the TLA+ checklist insrc/controller/CLAUDE.md.
Gap Re-Audit (2026-06-18)
POST-IMPLEMENTATION GAP AUDIT. Scope: requirements.md (Reqs 1–5, 19 acceptance criteria) vs on-disk code on
persistent_ha. tasks.md = 17/17[x]; spec.jsonphase: "implemented". Verified via codegraph_explore over the live source. This pass re-confirms the 2026-06-09 findings against code that has since absorbed adjacent specs (ha-prevote, ha-repl-hardening, ha-commit-durability, ha-convergence-recovery).
1. What changed since the last audit (2026-06-09)
- Req 5.5 ASAN leak — CLOSED. spec.json note (2026-06-10): test-fixture leaks fixed via
free(ep0)/free(ep1)in the two affected tests; controller suite green under ASAN. The prior "Partial" verdict is upgraded to Implemented. - spec.json phase drift — FIXED. Now
"implemented"(was"tasks-generated"). - Dispatcher evolved (cross-spec, still compliant).
peer_ctrl_msg_handler(ha.c:3600) now routesCTRL_PRIMARY→repl_ack_decode_and_dispatch(ha.c:3618) and added aCTRL_STANDBY && ha->raft.pre_vote_in_progress→pre_vote_reply_decode_and_dispatchbranch (ha.c:3610). The original design'sCTRL_PRIMARY_COMMITTINGcase is gone because that role was removed by ha-consistency-invariants. Still "set once, never reassigned" — Req 1.2 intact. handle_ctrlhellostrengthened. Now lives in config_repl.c:1436 (design.md said cluster.c — superseded, tasks.md was right). On first ctrlhello inCTRL_STANDBYwithprimary_ctrl_fd == -1it setsprimary_ctrl_fd = ep->fd, callsha_on_heartbeat_received, AND armsha_arm_heartbeat_timeout_vtask(ha.c:1447–1455). Exceeds original Req 3.1 (which only required designation) — also covers 3.3 from the receive path.- Compliant superset on the primary side. Late/failed peers are reconnected by
primary_heartbeat_fn→ha_connect_to_peer(ha.c:5103) via fresh outbound connects withstandby_ep_err_handler_primary. This is the primary acting as primary — does NOT violate Req 2.1 (standby passivity) and respects Req 4.2's "in that election round" scope.
2. Requirement-to-Asset Map
| Req | Summary | Code Asset (file:line) | Tag |
|---|---|---|---|
| 1.1 | One outbound TLS connection per peer for vote RPC | raft_cb_send_vote_request ha.c (+ PreVote raft_cb_send_pre_vote_request ha.c:223); SSL via ha->client_ssl_ctx |
CLOSED |
| 1.2 | Reuse surviving vote conn as persistent peer channel | peer_ctrl_msg_handler ha.c:3600 (set once on_vote_connected, never reassigned) |
CLOSED |
| 1.3 | Winner promotes vote conns → standby channels | raft_cb_become_leader promotion loop ha.c:~1056 (zarray_store(standby_endpoints, votes[i].ep)) |
CLOSED |
| 1.4 | Loser closes all outbound vote conns | raft_cb_become_follower close loop ha.c:~1179 (endpoint_fini(votes[i].ep); NULL-out) |
CLOSED |
| 2.1 | Standby initiates no outbound TCP to peers | fetch_config_from_primary + ha_find_and_connect_to_primary deleted; passive contract myapp.c:312 |
CLOSED |
| 2.2 | Standby processes ctrlhello/config/WAL on inbound conn | cluster.c myreq_handler → handle_ctrlhello config_repl.c:1436; config/WAL handlers unchanged |
CLOSED |
| 2.3 | No inbound comm within timeout → new election | ha_arm_heartbeat_timeout_vtask armed in raft_cb_become_follower (~ha.c:1238) + handle_ctrlhello (config_repl.c:1453) |
CLOSED |
| 2.4 | No tier-1/2/3 discovery on standby | ha_find_and_connect_to_primary removed entirely (exceeds design "remain for future use") |
CLOSED |
| 3.1 | First ctrlhello → designate primary control channel | handle_ctrlhello config_repl.c:1447–1455, idempotency guard primary_ctrl_fd == -1 |
CLOSED |
| 3.2 | RDHUP detected in one epoll cycle → CTRL_ELECTING | iomgr io_handler/handle_error netio.c:444–525 → err-handler → re-election |
CLOSED |
| 3.3 | Watchdog same timeout semantics as primary_ctrl_fd |
Same ha_arm_heartbeat_timeout_vtask (heartbeat_timeout_ms) ha.c:4825 |
CLOSED |
| 4.1 | Explicit close of vote endpoints on loss | endpoint_fini + NULL-out in raft_cb_become_follower (~ha.c:1181) |
CLOSED |
| 4.2 | Failed vote conn → denied vote, no reconnect that round | vote_ep_err_handler ha.c:3937: ELECTING→handle_request_vote_reply(grant=0); PRIMARY→remove from standby_endpoints + repl_remove_standby |
CLOSED |
| 4.3 | New election → fresh outbound connections | fresh ha_elect_ctx_t/eps per round in send-vote callback |
CLOSED |
| 5.1 | Config push via promoted connections | push_config_transport_only config_repl.c:879 over populated standby_endpoints |
CLOSED |
| 5.2 | WAL replication via promoted connections | repl_ack_decode_and_dispatch → ha_repl_ack_received; promoted ep registered via repl_add/repl_remove_standby |
CLOSED |
| 5.3 | Single-node self-elect, no peer connections | raft single-node shortcircuit (unchanged); ectx/NULL guards in both callbacks |
CLOSED |
| 5.4 | Committing timeout → degraded PRIMARY | promotion-fence/own-term-gate machinery (ha_arm_own_term_gate, fence timer ha.c:692) — superset of original committing_timeout_fn |
CLOSED |
| 5.5 | No new ASAN errors vs baseline | Test-fixture leaks fixed 2026-06-10 (spec.json note); suite green under ASAN | CLOSED |
Score: 19 of 19 acceptance criteria CLOSED. (Up from 18/19 + 1 Partial on 2026-06-09.)
3. Residual gaps
No functional or correctness gaps remain. Only documentation/metadata drift (cosmetic, non-blocking):
- R1 (LOW, doc): Stale comment ha.c:~4498 "Transition to CTRL_STANDBY so myapp_run schedules fetch_config_from_primary" — the function no longer exists; the standby path is passive. Comment is now misleading.
- R2 (LOW, doc):
raft_cb_become_followerwatchdog comment still says "election_timeout_ms" while the armed vtask usesheartbeat_timeout_ms(ha_arm_heartbeat_timeout_vtask). Same stale wording flagged on 2026-06-09; still present. - R3 (LOW, doc): Comment refs to
fetch_config_from_primary()/ha_find_and_connect_to_primarypersist in main.c:218, cluster.c:1209, ha.h:1091 as historical breadcrumbs. Harmless but reference removed symbols. - R4 (LOW, design drift): design.md still (a) places
handle_ctrlhelloin cluster.c (actual: config_repl.c) and (b) saysha_find_and_connect_to_primary"remains for future use" (actual: removed). design.md should be annotated "superseded" — tasks.md/spec.json are correct.
4. Integration risks (forward-looking, not gaps)
- Shared-asset coupling:
peer_ctrl_msg_handler,standby_endpoints, and the promoted-connection model are now load-bearing for ha-prevote (PreVote reply routing), ha-repl-hardening (heartbeat decision loop over promoted eps), and ha-commit-durability (promotion fence + own-term gate run on the primary that owns these eps). Any future change to the dispatcher's role switch or tostandby_endpointspopulation must re-run those specs' suites. The design.md "Revalidation Triggers" list already names these — keep it authoritative. - vctx lifetime:
vote_ep_err_handlerdeliberately does NOT freevctx(ha.c:3978) — it is freed inha_election_cleanup. With promoted connections outliving the election, confirmha_election_cleanup(or endpoint teardown) still reaches every promoted ep's vctx on a long-lived primary. Currently consistent, but it is the one lifetime invariant most exposed to drift.
5. Options for the open (cosmetic-only) work
Option A — Comment/design hygiene only (RECOMMENDED). Fix R1–R4 in place (4 comment edits + 2 design.md annotations). Effort: S (~30 min). Risk: Low (no code-behavior change). Closes the spec cleanly with zero outstanding items.
Option B — Leave as-is, note in steering. Record the stale-comment debt in .wolf/memory.md / a docs backlog and move on, since all 19 criteria are functionally CLOSED. Effort: XS. Risk: Low, but the misleading ha.c:4498 comment can mislead a future reader on the standby path.
Option C — Bundle into a cross-spec doc-sweep. Defer R1–R4 to a single documentation pass across the HA spec family (several specs carry similar removed-symbol breadcrumbs). Effort: M (coordinated). Risk: Low; best ROI if other specs share the debt.
| Item | Effort | Risk |
|---|---|---|
| R1–R3 stale comment fixes | S | Low |
| R4 design.md "superseded" annotations | S | Low |
| Optional: vctx-lifetime trace on long-lived primary (integration risk #2) | S | Low |
Verdict: feature COMPLETE — 19/19 acceptance criteria CLOSED. Remaining work is documentation hygiene only (Option A recommended).