myxce/.kiro/specs/libxce-failover/research.md
Rongsong Shen 20e59d0b42 checkpoint: bug-851 root cause + raft TLA+ conformance spec family
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.
2026-06-21 14:02:15 +00:00

248 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Research Log — libxce-failover
## Discovery Scope
Light, integration-focused discovery (extension of existing libxce). Goal: find the exact
seams to wire transparent failover into the client request/reply path. Verified by reading the
endpoint, client-config, request-core, reply, and controller-redirect code paths.
## Key Findings
### F1 — The async request/reply model determines the chokepoint (DECISIVE)
`xce_request(ep, req)` (request.c:317) only **encodes + enqueues** a packet — fire-and-forget.
The controller's reply (and its `status`) is delivered asynchronously: the endpoint's
`msg_handler` runs `handle_reply()` (xcectx.c:30) which signals the caller's wait channel, and
public ops block on `cbuf_get(&ctx->wbuf, &status)` (xceapi.c:116). Therefore the controller
status (`E_XCE_REDIRECT`/`NOMASTER`/`NOT_READY`/`STANDBY`) is observable only at the
**send-and-wait round trip**, not at `xce_request`. Failover orchestration must wrap that round
trip, where the `api_ctx_t` is in scope.
### F2 — `xce_request_with_retry()` production branch is a no-op stub
request.c:1972-1981: with no test hook installed, it does `xce_request(ep,req); return 0;`. The
entire retry/classify/backoff loop executes only under `xce_retry_send_hook` (tests). So the
"done" primitive cannot fail over against a real controller. Its hook contract
(`int hook(ep, req, reply_out)`) fakes a synchronous reply that production never produces.
### F3 — Redirect address is plumbed in the wire/struct but never populated or surfaced
- Wire schema has the fields: `controller.capnp:568-569` (`redirectHost`/`redirectPort`).
- Reply struct has them: `typemap.h:225-226` (`char *redirect_host; uint16_t redirect_port;`).
- Controller never sets them: every redirect site (`join.c:264`) sends `E_XCE_REDIRECT` with
`data_which = nodata`; grep finds **zero** production assignments to `reply.redirect_*`.
- Client never surfaces them: `handle_reply()` (xcectx.c:34) returns only `reply.status`; the
redirect fields are dropped before any caller sees them.
### F4 — Context already holds everything failover needs
`api_ctx_t` (client.c:23): `{ xce_client_config_t *conf; endpoint_t ep; xce_ctx_t ctx; }`.
`xce_client_config_t` (client.h:16): `masters[N_MAX_MASTERS]` + `master_ports[]` + `nmasters`.
So the master list, the endpoint to reconnect, and the reply channel are all reachable from one
context object. Missing: a current-master cursor, the retry config, and captured redirect target.
### F5 — Endpoint reconnect is straightforward and reusable
`endpoint_connect(ep, host, port)` (netio.c:659) does socket connect + SSL handshake on the
**same** `endpoint_t`. Reconnecting to a different master reuses the same endpoint object, so the
`msg_handler`/`owner` wiring that routes replies to `ctx->wbuf` stays intact.
### F6 — `xcecli_start()` only ever connects `masters[0]` (client.c:122)
No primary discovery at startup; single endpoint, never re-selected. Confirms the gap.
### F7 — Original EINVAL source
`xce_get_with_condition()` returns `-EINVAL` when `ctx->state != XCE_CTX_RUN` (xceapi.c:104).
After the primary dies the connection breaks and the ctx is left non-RUN, so every subsequent
call short-circuits to `-EINVAL` — the opaque error in the bug report. Failover must repair the
endpoint/ctx state instead of leaving it dead.
## Decisions (synthesis)
- **D1 (final — client-layer, supersedes the earlier xceapi round-trip idea):** failover lives in
the **`client.c` `xcecli_*` layer**, not at `xce_request` and not at `xceapi.c`. Evidence: every
consumer (worker `controller_connector.c`, scheduler `scheduler.c`, apiserver `clib.go`, xce_test)
calls `xcecli_*`, never `xce_*` directly; and only `api_ctx_t` (client.c) holds the master list —
`xce_ctx_t` (xceapi) does not. So `client.c` is both the universal surface and the only layer with
conf+ep+ctx co-located. **`xce_*` stay one-shot/fail-fast and unchanged**; each `xcecli_*` wraps
its `xce_*` call in a `do/while` driven by a shared `xcecli_retry_next` helper
(`src/lib/client_failover.c` + `client_internal.h`). Trade-off accepted (user): ~33 wrappers gain
a small loop (more LOC) but the policy is one helper and no async/thunk/plumbing changes — simpler
and lower-risk. The orphaned `xce_request_with_retry()` is removed, not wired.
- **D2:** retire the no-op production branch of `xce_request_with_retry()`. Keep `xce_retry_config_t`
+ `XCE_RETRY_DEFAULTS`. Realign the unit tests (and hook contract) to the real status taxonomy
and the round-trip shape (allowed by Req 8 / requirements decision; tracked in bug-608).
- **D3:** `handle_reply()` captures `reply.redirect_host/port` + status into the ctx (deep-copy the
host string before the decoded reply is freed) so the failover loop can prefer the address.
- **D4:** on endpoint error/RDHUP, the err path must release the pending `cbuf_get` with a
transport error so the loop reconnects instead of hanging.
- **D5:** controller populates `reply.redirect_host/port` from `ha.primary_host/port` at every
`E_XCE_REDIRECT` site (join.c confirmed sole site; audit during implementation).
- **D6:** budget-exhaustion mapping — no-primary cause → return distinct `-E_XCE_NOT_READY`
("cluster not ready"); transport-only cause → return last transport error. Never `-EINVAL`.
### Decisions added after design review (validate-design pass)
- **D7 (CI1 — reply correlation):** `ctx->wbuf` is single-slot, status-only
(`cbuf_init(...,1,sizeof(int))`, xcectx.c:185). `handle_reply` records `reply.seq` into the ctx;
`xcecli_retry_next` drains any stale `wbuf` entry after reconnect (and reconnect closes the old fd
first → no further old replies), so the next `xce_*` attempt cannot consume a prior-attempt reply.
- **D8 (CI2 — context state reset):** the existing transport-error path sets `ctx->state =
XCE_CTX_ERR` (xcectx.c:148), which the per-op guard rejects as `-EINVAL` (xceapi.c:14,…) — the
reported bug. `xcecli_retry_next` restores `XCE_CTX_RUN` after a successful reconnect, before the
next `xce_*` attempt, so the unchanged per-op guard passes. `xceapi.c` is not modified.
### Simplification pass (goal: keep libxce easy to use)
- **D13 (redirect = hint, not contract):** the controller still populates the redirect address
(forward-compatible, kept), but the client treats it as a **best-effort hint** — try it first if
present, else rotate the master list; correctness never depends on it. This removes the heavy
machinery the address-as-contract demanded: **drop the `reply_seq` correlation** (CI1 becomes
drain-after-close only — reconnect closes the old fd first, then drain `wbuf`), and **collapse the
classifier from 5 actions to 4** (`SUCCESS` / `RETRY_NOW` / `RETRY_BACKOFF` / `TERMINAL`) — the
hint is consumed by `EndpointRotator`, not a separate classifier branch. Net: fewer fields, no
lifetime-hazard dependency, simpler classification, same user-visible behavior. The rejoin hook
and per-ctx lock are kept (real needs). Rationale: with 3 masters and rare failover, rotation is
cheap; the hint is a latency nicety, not a correctness mechanism.
### Decisions added after 2nd design review (validate-design pass 2)
- **D10 (CI1 — op categories):** the ~33 `xcecli_*` ops split into reply-read (full failover,
`is_write=false`), reply-write (full failover, `is_write=true`, DUP-replay→success), and
fire-and-forget (`notify_*`/`heartbeat`/`wakeup` — `xce_*_req` only, no `cbuf_get`/status). The
failover classifier applies only to reply-bearing ops; fire-and-forget gets transport-reconnect
best-effort, and redelivery of lost notifies is deferred to `worker-rejoin-resync`.
- **D11 (CI2 — concurrency):** apiserver shares one ctx across goroutines (`clib.go:182`); add a
`failover.lock` taken per `xcecli_*` op so reconnect/rotate cannot race another in-flight call.
- **D12 (rejoin hook — user requirement):** stateful daemons (apiserver/worker/scheduler) need a
rejoin/state-exchange on reconnect; a worker also re-syncs its running-jobs state. libxce adds
`xcecli_set_reconnect_handler()`; the failover loop invokes it after reconnect, before resume. The
hook *content* is owned by `component-failover-adoption` (join) and `worker-rejoin-resync` (job
state). Pure clients (`xce_test`) register none.
- **D9 (CI3 — idempotent-write replay):** job submit carries a **client-generated** `jobid`
(`job.c:56`) with a server `-EEXIST`/`E_XCE_DUP_JOB` replay guard (`job.c:95`). The classifier maps
`DUP_JOB` on a re-sent write (`is_write && attempt>0`) to success; on attempt 0 it is a genuine
duplicate (terminal). This is what makes 5.2 actually satisfiable rather than assumed.
## Risks
- **R-async-blocking:** the round trip blocks longer under retry. Acceptable for CLI/apiserver
(already blocking); for async C daemons it's the `component-failover-adoption` spec's concern
(out of boundary here).
- **R-reply-routing on reconnect:** re-sending after reconnect must correlate the reply to the
same ctx wait channel; mitigated by reusing the same `endpoint_t` (F5) and matching on `seq`.
- **R-redirect-lifetime:** `redirect_host` is a decoded capnp string freed with the reply; must be
copied into a ctx-owned buffer (D3) to avoid use-after-free (cf. the `init_reply_by_req`
memset hazard already documented at request.c:243-250).
---
## Gap Re-Audit (2026-06-18)
**Method:** Forward-looking requirements-vs-codebase verification. Each requirement traced to a
real symbol/file:line in `src/lib`, `src/include`, `src/controller`. Every `[x]` task confirmed
against actual code (not just the checkbox). The only genuinely-open leaf task (6.2) re-scoped for
the remaining design/exec work.
### Verdict
**STATUS: PARTIAL — code-complete, live-E2E-incomplete.** All implementation tasks (1.1–6.1 + 5.3)
are present and wired in real code; the sole open leaf is **6.2 (live 3-node failover E2E)**, which
Implementation Notes flag as *partial* (server-side failover proven + 2 live bugs found/fixed, but
clean within-session client transparent-recovery never visually demonstrated in the sandbox env).
The 6 unchecked `[ ]` items besides 6.2 are **phase-group headers** (tasks 1/2/3/4/5/6), not work
units — all their leaf sub-tasks are `[x]` and verified below.
### Requirement-to-Asset Map
| Req | Summary | Tag | Evidence (file:line) |
|-----|---------|-----|----------------------|
| 1.1 | All ops (read+write) through failover | **MET** | `client.c` — 21× `XCECLI_FAILOVER_CALL`/`XCECLI_FF_CALL`; macro in `client_internal.h:152` |
| 1.2 | Next request completes on new primary | **MET** (unit) / **Unknown** (live) | `client_failover.c:xcecli_retry_next` + `xce_ctx_reconnect` (`xcectx.c:320`); live not visually confirmed (6.2) |
| 1.3 | Public API signatures unchanged | **MET** | Notes 5.1 "zero signature changes"; macro wraps op bodies, no proto edits |
| 2.1 | Unreachable → try other masters | **MET** | `xce_failover_next_target` (cursor advance+wrap over `conf->masters[]`), `client_failover.c` |
| 2.2 | Redirect → rotate, hint-first | **MET** | hint-first/consume then cursor; `xce_failover_rotate` |
| 2.3 | Standby → treat as redirect/rotate | **MET** | classifier `client_failover.c:59` (`E_XCE_STANDBY`→retry-now) |
| 2.4 | Sticky current primary | **MET** | `cur_master` in `xce_failover_state_t` (`client_internal.h`) |
| 2.5 | Redirect/standby/no-primary ≠ success | **MET** | classifier never maps these to SUCCESS; unit `test_libxce_retry.c` |
| 3.1 | No-primary → backoff to budget | **MET** | `client_failover.c:64` (NOMASTER/NOT_READY→backoff) |
| 3.2 | Success before budget | **MET** (unit) | `test_libxce_retry.c` become-primary-mid-loop |
| 3.3/3.4 | Budget exhausted → distinct cluster-not-ready (not EINVAL) | **MET** | `*rc = -E_XCE_NOT_READY` (`client_failover.c:282,317`) |
| 4.1–4.5 | Retriable vs terminal classification | **MET** | `xce_failover_classify`, 11-row taxonomy in `failover_harness.h`; tests pass |
| 5.1 | Reads+writes failover | **MET** | OpCategory: writes=true for jobsub/jobctrl/join/… (Notes 5.1) |
| 5.2 | Write retry not double-applied (idempotent DUP→success) | **MET** (client) / **Constraint** | classifier DUP-on-resent-write→success; relies on controller `job.c:56,95` jobid replay guard |
| 5.3 | No silent drop | **MET** | loop returns success or distinct error |
| 6.1/6.2 | Configurable params + safe defaults | **MET** | `xce_retry_config_t` + `XCE_RETRY_DEFAULTS` (`request.h:183-193`, max_retries=20) |
| 6.3 | Jitter / anti-thundering-herd | **MET** | `test_decide_jitter_staggers_many_clients` (64-carrier, Notes 6.1) |
| 7.1/7.2 | Log rotation target + exhaustion count | **MET** | `tlog_info`/`tlog_warn` in `xcecli_retry_next` (Notes 3.1) |
| 8.1 | Redirect carries primary host/port | **MET** | `fill_redirect_reply` (`join.c:28-31`) ← `ha.primary_host/port` (`ha.h:149-150`) |
| 8.2 | Unknown primary → no-primary, not addressless redirect | **MET** | `join.c:289` `-E_XCE_NOMASTER` path unchanged |
| 8.3/8.4 | Client uses hint best-effort, falls back to rotation | **MET** | `xce_ctx_capture_redirect_hint` (`xcectx.c:30,57`); rotate consumes-then-falls-through |
**Orphan cleanup (task 1.1):** confirmed removed — `xce_request_with_retry()` gone, only a
tombstone comment remains (`request.c:1927`).
**Controller audit (task 5.3):** `join.c` confirmed sole `E_XCE_REDIRECT` emitter; controller test
`test_join_redirect.c` registered (`controller_test.c:155`, `meson.build:97`).
Tally: **24/24 acceptance criteria MET in code** (1.2 live-unconfirmed; 5.2 carries one external
Constraint = controller idempotency, which is in-scope-consumed not in-scope-built).
### Open Tasks — Forward Gap Analysis
Only one leaf task is open. The remaining `[ ]` are phase headers that auto-close when their
sub-tasks are `[x]` (all are) — recommend marking 1/2/3/4/5 complete; keep 6 open until 6.2 lands.
#### Task 6.2 — Live 3-node failover end-to-end *(the real PENDING work)*
**What is missing (vs. what is done):**
- DONE in-env: 3-node `deploy-ha.sh` cluster elected a primary; killing it triggered server-side
failover (ctrl-2 → term=2); the run REPRODUCED the original `Invalid argument` bug and the failover
loop now ENGAGES (`xcecli_joblist`→`xcecli_retry_next`→`xce_failover_rotate`). Two live-only bugs
found+fixed with regression unit tests: **bug-609** (fresh-call-after-disconnect `-EINVAL`
classified terminal → known-dead-ctx override in `decide`) and **bug-610** (`xce_failover_rotate`
SEGV — reconnect never re-bound `ep->iomgr`/handlers; fixed via new `xce_ctx_reconnect()`).
- MISSING: a clean, *visually-captured* within-session demonstration of the three acceptance
outcomes against a stable runtime: (a) `joblist` reports cluster-not-ready during the election
window then transparently recovers with NO `EINVAL`; (b) a request to a standby lands on the
primary in one hop via the populated redirect hint (and via rotation when hint absent); (c) a
`jobsub` spanning the failover creates **exactly one** job (idempotency end-to-end).
**Integration points to exercise:** `/xce-deploy-ha localhost:9991 9992 9993` → `xce_test`
(`set masters`, `client`, `jobsub`, `joblist`) → kill primary → re-`joblist`. NOTE the build hazard
recorded twice in Notes: **building `lib` removes the controller binary from the shared build dir** —
must `xce-build.sh controller` (relinks `xce_test`) before each deploy.
**Options:**
- **Option A (recommended): stable-env manual E2E.** Run the reproduction in a non-sandbox env where
backgrounded controller procs survive between steps (the sandbox reaps them). Capture the 3 outcomes.
*Effort S–M, Risk Low* — code already proven to engage; this is confirmation + evidence capture.
- **Option B: scripted deterministic E2E harness** (a `testsuite` shell/expect script that boots the
cluster, kills the primary on a signal, asserts on `xce_test` output). *Effort M, Risk Med* — gives
a repeatable regression gate but adds harness maintenance; process-lifecycle flakiness is the risk.
- **Option C: in-process integration test** simulating the controller side with fakes (no real procs).
*Effort M–L, Risk Med* — avoids deploy flakiness but re-tests what unit tests already cover (low
marginal value vs. real-cluster confidence); does NOT satisfy 6.2's "original reproduction" intent.
**Recommendation:** Option A to *close* 6.2 (intent = the original bug reproduction passes live),
optionally backstopped by Option B as a CI regression gate in a follow-on.
#### Phase headers 1–5 (and 6) — administrative
*Gap:* checkbox-only. *Action:* flip 1/2/3/4/5 to `[x]` (all sub-tasks verified `[x]` above); leave
6 `[ ]` until 6.2 closes. *Effort S, Risk None.*
### Research-Needed (for any remaining design/exec)
1. **Stable runtime for live E2E** — identify an env where backgrounded `xce_controller` procs
persist across orchestration steps (sandbox reaps them; nix runtime env needed). Blocks 6.2-A.
2. **Build-order guard** — codify "rebuild controller after any `lib` build" (Notes 3.1/6.2 twice).
Consider a `xce-build.sh` ordering note or a make dependency so `xce_test` is never stale-linked.
3. **Idempotency end-to-end proof (5.2)** — confirm the controller's client-`jobid` replay guard
(`job.c:56,95`) actually returns `E_XCE_DUP_JOB`/`-EEXIST` on the *re-sent* write across a real
failover (unit-mocked today; live single-job-created assertion is the open evidence).
4. **Downstream readiness (out-of-boundary, flag only)** — `component-failover-adoption` (HARD dep)
still owes: per-daemon **full master-list** wiring (today even CLI uses `masters[0]`,
`client.c:131`) + **liveness-safe** bounded-blocking verification for async C daemons (worker
`controller_connector.c`, scheduler) so backoff never stalls the heartbeat/iomgr thread. Not 6.2's
scope, but it is the gate before this failover mechanism delivers value to real daemons.
### Integration Risks (remaining)
- **Process-lifecycle flakiness** in any live/scripted E2E (controller procs, port reuse, election
timing) — the dominant risk for 6.2; mitigated by Option A in a stable env + jittered election.
- **Stale `xce_test` link** if controller isn't rebuilt after a `lib` build — would mask a real fix
or surface a phantom one. Mitigation = Research-Needed #2.
- **Idempotency dependency** (5.2) is external (controller `job.c`) — a regression there breaks
exactly-once across failover without touching libxce. Mitigation = Research-Needed #3.