# Gap Analysis: ha-config-bootstrap *Generated: 2026-05-27. Branch: persistent_ha.* --- ## Analysis Summary - **Scope**: 10 requirements across 8 distinct changes (A–H). The codebase has strong foundational infrastructure (iomgr, repl slot management, WAL, SSL) but the HA election core, quorum commit, and control channel are mostly absent or stubbed. - **Largest gaps**: Raft-style quorum election (Req 2) and persistent control channel with full state sync (Req 6) are full rewrites/additions with no current equivalent logic; majority-commit WAL (Req 5) requires implementing the two stub functions at the heart of replication. - **Strong reusable assets**: `repl_add_standby/remove/start_catchup/complete_catchup/needs_snapshot` are real implementations; the SSL connect pattern (`connect_ssl_raw`), xorshift64 PRNG, `ha_do_promote/demote`, and iomgr epoll machinery are all directly reusable. - **Risk concentration**: Req 2 and Req 6 carry High risk; Req 5 carries High risk due to `committed_lsn` cross-cutting `persist_t`, `repl.c`, and `ha_state_t`. The remaining requirements are Medium or Low. - **Recommended approach**: Hybrid (Option A) — implement changes A→D→B→C→E→F→G→H in dependency order, writing C tests first per TDD, one atomic commit per change. --- ## 1. Current State Investigation ### 1.1 HA Core (ha.h / ha.c) **`ctrl_role_t` enum** — only two values: `CTRL_PRIMARY = 0`, `CTRL_STANDBY = 1`. Missing: `CTRL_INIT`, `CTRL_ELECTING` (required by Req 1). **`ha_state_t`** — current fields of interest: | Field | Status | Notes | |---|---|---| | `term` (_Atomic uint64_t) | Exists | Used for fencing | | `role` (_Atomic int32_t) | Exists | Cast to ctrl_role_t | | `my_priority` | Exists | LSF priority index — kept for compat but unused in Raft | | `applied_lsn` (_Atomic uint64_t) | Exists | Reusable for vote freshness check | | `primary_host[XCE_MAXNAME_LEN]` | Exists | Reusable | | `primary_port` | Exists | Reusable | | `last_heartbeat` (struct timespec) | Exists | Replaces with `EPOLLRDHUP` path in Req 6 | | `in_catch_up` (_Atomic int) | Exists | Reusable for Clarification 6 vote exclusion | | `jitter_seed` (uint64_t) | Exists | xorshift64, directly reusable | | `ssl_ctx` (void*) | Exists | Reusable | | `standby_endpoints` (zarray_t*) | Exists | Superseded by per-standby fd table in Req 6 | | `voted_for_term` (uint64_t) | **MISSING** | Req 9 | | `voted_for_name` (char[]) | **MISSING** | Req 9 | | `primary_ctrl_fd` (int) | **MISSING** | Req 6 | | `defer_config_fetch` (bool) | **MISSING** | Req 3 | | `elect_ctx` (_Atomic ptr) | **MISSING** | Req 2 | | `last_known_committed_lsn` (_Atomic uint64_t) | **MISSING** | Req 5 | | `heartbeat_seen` (sem_t) | **MISSING** | Req 1 | **`ha_config_t`** — missing: `commit_timeout_ms`. All election fields present (election_timeout_ms, jitter_max_ms, etc.) but values tuned for LSF model. **`am_i_primary()`** — LSF priority probe via per-peer pthreads. No quorum, no config_tag/LSN in vote, no term/voted_for persistence. Must be replaced entirely with `am_i_primary_quorum()`. **`ha_check_priority_stepdown()`** — permanently unreachable once term > 0; exists only for the term=0 simultaneous-boot race. Must be retired (Req 2). **`ha_check_heartbeat_nocluster()`** — timestamp polling with configurable timeout. Replaced by `EPOLLRDHUP` fast path + heartbeat timeout vtask (Req 6). Function itself can be deleted. **`ha_watchdog_thread()`** — sets `failover_requested` but cannot call `ha_promote_to_primary()` (no `cluster_t`). Partially functional stub. Replaced by vtask-based heartbeat monitor (Req 6). **`start_heartbeat_listener()` / `stop_heartbeat_listener()`** — no-op stubs with comment "full implementation in cluster.c Task 7". **Missing** (Req 1). **`ha_fini()`** — missing `SSL_CTX_free(ha->ssl_ctx)`. ASAN-visible leak (~5 KB). ### 1.2 Startup Sequence (myapp.c / main.c) **myapp.c current order**: ``` cluster_init_phase1() initialize_openssl() + create_ssl_context() am_i_primary() ← election here load_primary_config() / fetch_config_from_primary() cluster_init_phase2() ← iomgr, vtaskmgr, bind+listen inside cluster_start() ← more network init cluster_run() ``` **Bind+listen** happens inside `cluster_init_phase2()` or `cluster_start()` — **after** election. Gap: concurrent-startup nodes probe an unbound port (Req 1). **No `bootstrap_config_if_needed()`** — standby path goes straight to `fetch_config_from_primary()` post-election without checking whether local config_tag is 0 or whether peers have config. Gap (Req 3). **main.c**: - `--vcpus N` arg: **MISSING**. vcpus comes from cluster config read inside phase2. This creates a dependency that prevents vtaskmgr from running before election (Req 1, 4). - Peer count validation: **MISSING**. Even-N peer lists accepted silently (Req 4). - Cert pre-flight `access()` check: **MISSING**. Cert paths computed but SSL context created later in myapp.c, not with early-exit error in main.c (Req 4). ### 1.3 Replication (repl.c / repl.h) | Function | Status | Notes | |---|---|---| | `repl_init / fini` | Real | Lifecycle ok | | `repl_add_standby` | Real | Slot management implemented | | `repl_remove_standby` | Real | Implemented | | `repl_start_catchup` | Real | Sets in_catch_up=1 | | `repl_complete_catchup` | Real | Clears in_catch_up, broadcasts cond | | `repl_needs_snapshot` | Real | Compares standby_lsn vs oldest_wal_lsn | | `repl_on_ack` | Real | Updates acked_lsn, signals cond | | `repl_trim_lsn` | Real | WAL trim guard implemented | | `repl_broadcast` | **STUB** | Returns 0 without sending anything (Req 5, 8) | | `repl_wait_ack` | Partial | Waits for ANY non-catch-up standby, not majority (Req 5) | | `repl_wait_majority_ack` | **MISSING** | Req 5, 8 | **`standby_conn_t`** — missing `pending_ack_count` for backpressure (Req 5). ### 1.4 Persistence (persist.h / persist.c) - `current_lsn` and `checkpoint_lsn` exist. **`committed_lsn`** — **MISSING** (Req 5). - `persist_wal_sync_append()` uses `repl_wait_ack()` (any-standby) — must upgrade to `repl_wait_majority_ack()`. - `persist_truncate_wal_above()` — **MISSING** (Req 5, 6). - `persist_config_t.sync_timeout_ms` exists — analogous to `commit_timeout_ms` needed for majority-commit. ### 1.5 Config Replication (config_repl.c) - `do_one_config_fetch()` — **wired** (recent commit `2aae45f` on branch: "wire do_one_config_fetch() — full SSL roundtrip"). Not a stub. - `bootstrap_config_if_needed()` — **MISSING** (Req 3). - `xce_probe_config_tag()` — **MISSING**. New lightweight RPC needed (Req 3). - `push_config_to_standbys()` — declared in config_repl.h; likely fire-and-forget (no majority wait). **Gap**: must upgrade to majority-commit (Req 8). - `OP_probeConfigTag` Cap'n Proto schema — **MISSING** (Req 3). - `OP_upgradeConfigAck` Cap'n Proto schema — **MISSING** (Req 8). - `committed_config_tag` — **MISSING** (Req 8). ### 1.6 Control Channel (ha.c / cluster.c) - No persistent standby-to-primary TCP connection. Each config fetch and WAL entry opens a new SSL connection via `connect_ssl_raw()`. - `ha_find_and_connect_to_primary()` — **MISSING** (Req 6). - Extended `OP_ctrlHello` with applied_lsn, config_tag — **MISSING** (Req 6). - `OP_snapshotTransfer`, `OP_walCatchupEntry` — **MISSING** (Req 6). - `handle_ctrlhello()` extended handler — **MISSING** (Req 6). - Three-tier primary discovery — **MISSING** (Req 6). - EPOLLRDHUP failover detection — **MISSING** (Req 6). - Heartbeat timeout vtask replacing watchdog thread — **MISSING** (Req 6). ### 1.7 Election State Durability - `ha-term.bin` file — **does not exist**. - `ha_persist_term()` — **MISSING** (Req 9). - `ha_init()` zeros term and has no `ha-term.bin` read path. ### 1.8 libxce Client Retry (src/lib/) - `ha_should_redirect()` / `ha_no_primary_known()` exist in ha.c and are used by join.c. - No transparent retry loop for `XCE_ERR_NOT_READY`, `XCE_ERR_STANDBY`, `ECONNREFUSED`. - `xce_retry_config_t` — **MISSING** (Req 7). - `redirect_host` / `redirect_port` fields in `XCE_ERR_STANDBY` error reply — **MISSING** (Req 7). --- ## 2. Requirement-to-Asset Map | Req | Key Gap | Key Reusable Asset | Gap Tag | |---|---|---|---| | 1 — Listener before election | `CTRL_INIT`/`CTRL_ELECTING` enum values; pre-dispatch gate in vtaskmgr; extract bind+listen from phase2; `ha_wait_for_heartbeat_or_timeout()`; `heartbeat_seen` semaphore | iomgr epoll, vtaskmgr dispatch switch, xorshift64 jitter | **Missing** | | 2 — Quorum election | Entire `am_i_primary_quorum()` and vote protocol; `ha_vote_entry_t`, `ha_elect_ctx_t`; `handle_request_vote()` / `_reply()`; `ha_elect_evaluate_and_signal()`; OP_requestVote capnp schema | `ha_do_promote/demote`, jitter PRNG, `applied_lsn`, iomgr async connect | **Missing** | | 3 — Config bootstrap | `bootstrap_config_if_needed()`; `xce_probe_config_tag()`; OP_probeConfigTag capnp; `defer_config_fetch` flag in ha_state_t | `do_one_config_fetch()` (wired), `connect_ssl_raw()` | **Missing** | | 4 — Startup validation | `--vcpus N` CLI arg; even-N peer count check; cert pre-flight `access()`; `SSL_CTX_free` ASAN fix | argp option parsing pattern in main.c | **Missing** | | 5 — Majority-commit WAL | `repl_broadcast()` implementation; `repl_wait_majority_ack()`; `committed_lsn` in persist_t; `persist_truncate_wal_above()`; `committed_lsn` in heartbeat; `last_known_committed_lsn` in ha_state_t | `repl_wait_ack()` logic, `standby_conn_t`, `repl_start/complete_catchup`, WAL sync path | **Missing** / **Stub** | | 6 — Persistent channel | Persistent TCP fd per standby; `ha_find_and_connect_to_primary()`; EPOLLRDHUP failover; extended OP_ctrlHello; OP_snapshotTransfer; OP_walCatchupEntry; WAL streaming loop; heartbeat vtask; three-tier discovery | iomgr EPOLLIN/EPOLLRDHUP, `repl_needs_snapshot()`, `repl_start/complete_catchup()` | **Missing** | | 7 — Client retry | Retry loop in libxce; `xce_retry_config_t`; redirect fields in error reply | `ha_should_redirect()`, `ha_no_primary_known()` | **Missing** | | 8 — Majority-commit config | `repl_wait_majority_ack()` (shared with Req 5); OP_upgradeConfigAck; `committed_config_tag`; heartbeat piggybacking | `push_config_to_standbys()` (fire-and-forget → upgrade) | **Missing** | | 9 — State durability | `ha_persist_term()`; `ha-term.bin` read in `ha_init()`; `voted_for_term`/`voted_for_name` in ha_state_t | Atomic write+rename pattern (existing in config_repl.c) | **Missing** | | 10 — Observability | Structured log statements throughout all new code paths | Existing INFO/WARN/ERROR macros (xcelog.h) | **Missing** (alongside) | --- ## 3. Implementation Approach Options ### Option A: Change-at-a-time (recommended) Implement changes in dependency order: D → A → B → C → E → H → F → G → (observability woven in). **Rationale for ordering**: 1. D first: `--vcpus N` removes vtaskmgr's config dependency, unblocking A. 2. A before B: listener must be open before election dispatch works. 3. B before C: config bootstrap needs the proven election winner from quorum election. 4. E and H together: both use `repl_broadcast()` + `repl_wait_majority_ack()`; implement infrastructure once. 5. F after E: persistent channel carries WAL entries; broadcast must send over that channel. 6. G last: retry makes sense only after the controller states it retries against are implemented. **Files touched per change**: - D: `main.c`, `cluster.c` (remove vcpus from config), `ha.c` (`SSL_CTX_free`) - A: `ha.h` (enum + semaphore + fields), `myapp.c` (startup reorder), `cluster.c` (extract listener), `ha.c` (wait function, dispatch gate) - B: `ha.h` (new types), `ha.c` (replace `am_i_primary`), capnp schema, `cluster.c` (dispatch handler) - C: `config_repl.c` (`bootstrap_config_if_needed`, `xce_probe_config_tag`), capnp schema, `myapp.c` (integration) - E: `repl.c` (`repl_broadcast`, `repl_wait_majority_ack`), `persist.h`/`persist.c` (`committed_lsn`, `persist_truncate_wal_above`), `ha.h`/`ha.c` (`last_known_committed_lsn`) - H: `config_repl.c` (upgrade majority-commit), capnp schema, `ha.h` (`committed_config_tag`) - F: `ha.c`/`cluster.c` (channel connect, extended hello, streaming, EPOLLRDHUP) - G: `src/lib/` (retry loop, xce_retry_config_t, redirect fields) **Trade-offs**: - ✅ Each atomic commit is testable before the next starts - ✅ Follows existing TDD convention (failing test first) - ❌ Long sequential dependency chain; F and G can only start late ### Option B: Infrastructure-first parallel tracks Track 1 (election+bootstrap): D → A → B → C Track 2 (replication): E → H Join (channel): F using Track 1 + Track 2 artifacts Final: G **Trade-offs**: - ✅ Track 1 and Track 2 are mostly independent until F - ❌ Track 2 cannot be fully validated without Track 1 (no quorum without election) - ❌ More coordination overhead between developers on shared ha.h ### Option C: Stub-forward then wire Add all new stubs with `-ENOSYS` first, then wire each one. Matches the existing codebase pattern (many stubs already present). **Trade-offs**: - ✅ Compiles throughout; no long broken-build windows - ❌ Stub-first delays real testability; TDD mandate disfavors this **Recommendation**: Option A. The dependency chain is well-defined and single-developer serial implementation is the realistic mode for this C codebase. --- ## 4. Effort and Risk Summary | Req | Effort | Risk | Justification | |---|---|---|---| | 1 — Listener before election | M (3–5d) | Medium | Refactor startup order; must remove vtaskmgr/config dependency first | | 2 — Quorum election | XL (2+ wks) | High | Full algorithm replacement; new wire protocol; FSM extension; concurrent dispatch | | 3 — Config bootstrap | M (3–5d) | Medium | New function + probe RPC; depends on do_one_config_fetch (wired) | | 4 — Startup validation | S (1d) | Low | Additive CLI + validation; ASAN one-liner | | 5 — Majority-commit WAL | L (1–2 wks) | High | `committed_lsn` cross-cuts persist+repl+ha; `repl_broadcast` must become real | | 6 — Persistent channel | XL (2+ wks) | High | New connection lifecycle; full state sync protocol; 3-tier discovery | | 7 — Client retry | M (3d) | Low | Well-understood retry pattern; additive libxce change | | 8 — Majority-commit config | M (3d) | Medium | Shares Req 5 infrastructure; mainly wiring push_config_to_standbys | | 9 — State durability | S (1d) | Low | File write+read; extends ha_init | | 10 — Observability | S (alongside) | Low | Log statements woven into each change | **Total estimated effort**: 6–9 weeks for one engineer implementing with full TDD. **High-risk dependencies**: - Req 2 must complete before Req 3 can be integrated end-to-end. - Req 5 (`repl_broadcast` implementation) must complete before Req 8 and before Req 6 (WAL streaming over the channel). - Req 4 (vcpus CLI) must complete before Req 1 (vtaskmgr before election). --- ## 5. Key Reusable Assets | Asset | Location | Reuse in | |---|---|---| | `repl_add/remove_standby` | repl.c | Req 6 (channel registration) | | `repl_start/complete_catchup` | repl.c | Req 6 (state sync protocol) | | `repl_needs_snapshot` | repl.c | Req 6 (snapshot decision) | | `repl_on_ack` | repl.c | Req 5 (majority-commit ack handler) | | `repl_trim_lsn` | repl.c | Req 5 (WAL trim with committed_lsn) | | `connect_ssl_raw()` | netio.c/config_repl.c | Req 3 (probe RPC) | | `do_one_config_fetch()` | config_repl.c | Req 3 (deferred fetch) | | `xorshift64_next + ha_seed_jitter` | ha.h / ha.c | Req 1, 2 (jitter) | | `ha_do_promote / ha_do_demote` | ha.c | Req 2, 6 (role transitions) | | `ha_should_redirect / ha_no_primary_known` | ha.c | Req 7 (retry redirect) | | iomgr epoll + EPOLLIN/EPOLLRDHUP | iomgr (cluster.c) | Req 1, 6 | | Atomic write+rename pattern | config_repl.c | Req 9 (ha-term.bin) | | INFO/WARN/ERROR macros | xcelog.h | Req 10 | --- ## 6. Constraints for Design Phase - **C gnu17, no C++, no new external dependencies**. - ASAN build (`nix build .#xcecore_debug`) must pass after every change. - **Static peer membership**: `--peers` list is fixed at startup; no dynamic join/leave of controller peers. Quorum denominator is fixed for cluster lifetime. - **TDD mandatory**: failing test in `src/controller/testsuite/` before each implementation change. - `make test` must pass without a live server at every commit. - `commit_timeout_ms` must be configurable (default 5000ms). - **Single-node mode** (no `--peers`): quorum=1 everywhere; all new majority-commit paths must short-circuit to self-only. - **WAL write stall window** (operational constraint, not a bug): during standby catch-up after election, `repl_wait_majority_ack()` will time out on every write until at least one standby completes catch-up. This is correct CP behavior; operators must be warned in docs. --- ## 7. Research Needed (Design Phase) - **vtaskmgr API for pre-election dispatch**: confirm that `vtaskmgr_init(vcpus)` can be called before `cluster_config` is loaded (after removing vcpus from config struct). Verify no hidden config dependency. - **iomgr async connect pattern for vote requests**: confirm `iomgr_async_vote_request()` can be implemented using existing async connect infrastructure without new socket primitives. - **capnp schema extension**: confirm the capnp code-generation toolchain can add `OP_requestVote`, `OP_probeConfigTag`, `OP_walAck`, `OP_upgradeConfigAck`, `OP_snapshotTransfer`, `OP_walCatchupEntry` without breaking existing decode paths. - **`persist_truncate_wal_above()` safety**: verify WAL segment file structure supports efficient truncation at arbitrary LSN boundary without full segment rewrite. - **libxce client API surface**: identify the exact function in src/lib/ that wraps xce_encode()+send+recv+xce_decode() to locate the correct insertion point for retry logic.