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.
404 lines
34 KiB
Markdown
404 lines
34 KiB
Markdown
# 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
|
||
|
||
```c
|
||
/* 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`:
|
||
|
||
1. `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 call** `fetch_config_from_primary`. ✅
|
||
2. `ha_elect_evaluate_and_signal` (ha.c:~1702–1744) — the older custom election path (invoked directly by `handle_request_vote_reply` / vote-thread path). This path **still calls** `fetch_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:
|
||
|
||
1. **(Critical — Req 2.1, 2.4)** Remove `fetch_config_from_primary` call from `ha_elect_evaluate_and_signal` STANDBY path (ha.c:1737–1743), or confirm the function is never reached on the STANDBY branch in production. Mirror `ha_arm_heartbeat_timeout_vtask` call if the path is still live.
|
||
2. **(Verification — task 7.1)** Run `/xce-build controller` to confirm all unit tests in `test_ha_connection_reuse.c` pass and ASAN is clean.
|
||
3. **(Integration — task 7.2)** Add two-node test verifying standby does not call `ha_find_and_connect_to_primary`.
|
||
4. **(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-gap` across 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_primary` call in `ha_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 from `CTRL_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 in `test_ha_connection_reuse.c` pass under ASAN.
|
||
- **Cross-spec relationships**: `ha-repl-hardening` commit `0762577` (task 5.1: "reduce handle_ctrlhello to connection setup only") directly depends on this spec's `peer_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 to `ha.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 in `src/controller/testsuite/test_ha_connection_reuse.c` (registered in `src/controller/meson.build:119`).
|
||
- The dispatcher (`peer_ctrl_msg_handler`, ha.c:2570) is set once in `on_vote_connected` (ha.c:2321, 3586) and routes by `ha->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-NULL `votes[i].ep` to `ha->standby_endpoints` under `ha->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) calls `endpoint_fini` on each vote ep, sets `CTRL_STANDBY`, then arms `ha_arm_heartbeat_timeout_vtask` — no outbound activity afterward.
|
||
- Standby side: `handle_ctrlhello` (config_repl.c:1357–1374) sets `primary_ctrl_fd = ep->fd` (idempotent `== -1` guard) and resets the liveness watchdog via `ha_on_heartbeat_received`.
|
||
- The tier-1/2/3 discovery path is fully removed: `ha_find_and_connect_to_primary` and `fetch_config_from_primary` no 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 uses `heartbeat_timeout_ms`.
|
||
- design.md places `handle_ctrlhello` in `cluster.c`; the actual handler lives in `config_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 `phase` is still `"tasks-generated"` although all tasks are `[x]` and tests pass.
|
||
- Working tree carries uncommitted changes (`ha_init_raft` extraction, vote-gate now requires `primary_ctrl_fd >= 0` for 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_vtask` ctx leak: either free the armed ctx in the test teardown via the existing cancel flag, or gate the arm behind the existing `c == NULL` test seam in `handle_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.sh` that 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
|
||
|
||
1. **Adopt Option A (or C)**: eliminate the reported leaks rather than litigating the baseline — a leak-free run satisfies Req 5.5 unconditionally.
|
||
2. Verify the production-side lifetime of the `ha_arm_heartbeat_timeout_vtask` ctx when `handle_ctrlhello` re-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).
|
||
3. 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`).
|
||
4. 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_ctx` ownership 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_raft` extraction and the `primary_ctrl_fd >= 0` vote-gate tightening (interacts with Req 3.1's designation semantics), and validate against the TLA+ checklist in `src/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.json `phase: "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 routes `CTRL_PRIMARY` → `repl_ack_decode_and_dispatch` (ha.c:3618) and added a `CTRL_STANDBY && ha->raft.pre_vote_in_progress` → `pre_vote_reply_decode_and_dispatch` branch (ha.c:3610). The original design's `CTRL_PRIMARY_COMMITTING` case is gone because that role was removed by ha-consistency-invariants. Still "set once, never reassigned" — Req 1.2 intact.
|
||
- **`handle_ctrlhello` strengthened.** Now lives in config_repl.c:1436 (design.md said cluster.c — superseded, tasks.md was right). On first ctrlhello in `CTRL_STANDBY` with `primary_ctrl_fd == -1` it sets `primary_ctrl_fd = ep->fd`, calls `ha_on_heartbeat_received`, AND arms `ha_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 with `standby_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_follower` watchdog comment still says "election_timeout_ms" while the armed vtask uses `heartbeat_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_primary` persist 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_ctrlhello` in cluster.c (actual: config_repl.c) and (b) says `ha_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 to `standby_endpoints` population must re-run those specs' suites. The design.md "Revalidation Triggers" list already names these — keep it authoritative.
|
||
- **vctx lifetime:** `vote_ep_err_handler` deliberately does NOT free `vctx` (ha.c:3978) — it is freed in `ha_election_cleanup`. With promoted connections outliving the election, confirm `ha_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).**
|