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.
248 lines
18 KiB
Markdown
248 lines
18 KiB
Markdown
# 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.
|
||
|