myxce/.kiro/specs/libxce-failover/brief.md

150 lines
8.8 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.

# Brief: libxce-failover
## Problem
Client applications using libxce do **not** survive controller primary failover. When the
primary controller is killed and a new one is elected, in-flight and subsequent libxce calls
fail with an opaque `Invalid argument` (EINVAL) instead of either (a) retrying transparently
against the new primary or (b) reporting a clear "cluster is not ready" during the election
window.
Reproduction (3-node HA cluster, `set masters localhost:9991,localhost:9992,localhost:9993`):
1. Primary elected; `jobsub` / `joblist` work.
2. Kill the primary (`ctrl-1`).
3. Before a new primary is elected, `joblist` should report **cluster not ready**.
4. After a new primary is elected, all commands should transparently route to the new
primary with no error.
Observed instead: `joblist` keeps returning stale cached output, then `xce_joblist: Invalid
argument` indefinitely — the client never fails over.
## Current State
- The requirement already exists: **`ha-config-bootstrap` Requirement 7 — "Client-Side
Transparent Retry for Transient Controller Errors"** (acceptance criteria 7.1–7.6), and its
tasks 10 / 10.1 / 10.2 are marked complete.
- Only the **standalone primitive** was delivered:
- `xce_request_with_retry()` is implemented at `src/lib/request.c:1956` and unit-tested in
`src/lib/testsuite/test_libxce_retry.c` (NOT_READY backoff, STANDBY+redirect reconnect,
STANDBY-without-redirect fallback, non-retriable surface-immediately, max-retries,
50-client concurrency). `xce_retry_config_t` defaults: `max_retries=20`,
`initial_retry_ms=50`, `max_retry_ms=5000`, `backoff_multiplier=2.0`.
- **Gap 1 — wiring:** every production API path bypasses the wrapper. `xce_joblist` →
`xce_get_req` (`src/lib/request.c:1373`) → plain `xce_request()` (`request.c:1385`). The
same is true for ~18 other `xce_*_req` wrappers — none call `xce_request_with_retry()`.
- **Gap 2 — multi-master discovery:** the retry primitive operates on a single `endpoint_t`
and only follows an explicit `redirect_host/redirect_port`. A *killed* primary cannot
redirect, so the client must **rotate through its configured master list** (9991/9992/9993)
to discover the new primary. That orchestration does not exist; the client connects to
`masters[0]` once in `xcecli_start()` (`src/lib/client.c:122`) and never re-selects.
- Connections are long-lived and cached in `api_ctx_t->ep`; there is no dead-primary
detection or automatic reconnect at the API level.
## Desired Outcome
Failover is transparent to client applications:
- Short failovers are hidden: libxce retries across the configured masters with backoff and
succeeds against the new primary once elected.
- If the election outlasts the retry budget, the call returns a clear `E_XCE_NOT_READY`
("cluster not ready") rather than `EINVAL`.
- Both read and write requests fail over (writes rely on existing server-side commit-barrier +
idempotency guards to dedup re-sends).
- No per-daemon ad-hoc retry loops — the behavior lives entirely in libxce.
## Approach
**Central chokepoint (Approach A).** Make failover the default behavior at the single point
all wrappers already funnel through:
1. Give the request layer access to the cluster/master list + a "current primary" index
(design choice: a back-reference on `endpoint_t`, or a small `xce_cluster_t` the endpoint
references — resolved in design phase).
2. Upgrade `xce_request_with_retry()` to, in addition to its existing redirect/backoff cases:
- rotate through the master list on connection failure (`ECONNREFUSED`/`ETIMEDOUT`/RDHUP) or
`STANDBY`-without-redirect, reconnecting to each candidate to find the new primary;
- apply the bounded backoff budget and, on expiry, surface `E_XCE_NOT_READY`.
3. Route the production `xce_*_req` wrappers through the retry/failover path (e.g. make
`xce_request()` the failover wrapper over an inner `xce_request_raw()`), wiring every API
at once.
Chosen because it is the only approach that covers the entire client surface at one
integration point, reuses the already-tested primitive, treats reads and writes uniformly, and
keeps the requirement out of caller code — matching "transparent to the client application."
## Scope
- **In:**
- Wire all production libxce request paths through transparent retry/failover.
- Multi-master rotation to discover the new primary after the connected one dies.
- Bounded-budget semantics: transparent retry, then `E_XCE_NOT_READY` ("cluster not ready").
- Failover for both read and write requests (rely on server idempotency for writes).
- Tests: extend `test_libxce_retry.c` for master-list rotation + budget→NOT_READY; a 3-node
live failover integration test (kill primary, assert NOT_READY window then transparent
recovery).
- **Out:**
- Server-side election, WAL, commit barrier, idempotency guards (already owned by the HA
server specs; this spec consumes them).
- Changing the `xce_test` REPL behavior beyond what surfaces the new error semantics.
- Per-request idempotency/retry opt-in flags (writes retry by default per decision).
## Boundary Candidates
- Master-list + current-primary selection state and rotation policy.
- The retry/failover wrapper (backoff budget, error classification, NOT_READY surfacing).
- Integration seam: how production `xce_*_req` wrappers reach the failover path.
- Error-surface mapping: connection/EINVAL vs. `E_XCE_NOT_READY` vs. `E_XCE_STANDBY` vs.
non-retriable (auth/perm/invalid/noent).
## Out of Boundary
- Any controller/server-side change (election timing, redirect content, idempotency).
- New transport/probe opcodes unless design shows rotation cannot work with existing replies.
## Upstream / Downstream
- **Upstream:** `ha-config-bootstrap` (Req 7 retry primitive + `xce_retry_config_t` + error
codes `E_XCE_NOT_READY` / `E_XCE_STANDBY` + `redirect_host/port` reply fields);
`ha-commit-barrier-rollout` (server idempotency guards that make write retries safe);
`ha-single-leader-role` / election specs (define when a standby/primary answers).
- **Downstream:** worker, scheduler, and apiserver clients — all gain transparent failover and
can drop any ad-hoc retry handling; the `/xce-deploy-ha` live test harness.
## Existing Spec Touchpoints
- **Extends:** consumes and completes the intent of `ha-config-bootstrap` Req 7 (the primitive
was delivered but never integrated); this spec owns the integration + multi-master failover.
- **Adjacent:** `ha-repl-hardening`, `ha-convergence-recovery` (server-side failover behavior);
`worker-rejoin-resync` (worker-side rejoin, distinct from client-library failover).
## Protocol Correction (2026-06-16, requirements phase)
The discovery notes above assumed the controller signals non-primary via `E_XCE_STANDBY` with
`redirect_host/redirect_port` fields. Code review found the real taxonomy (`errnox.h:22-25`):
`E_XCE_REDIRECT` (standby knows a primary exists; `join.c:260-268`), `E_XCE_NOMASTER` (standby,
no primary known yet; `join.c:270-278`), `E_XCE_NOT_READY` (INIT/ELECTING), `E_XCE_STANDBY`
(primary-only op on a standby). The controller's redirect path sends `E_XCE_REDIRECT` with
`data_which = nodata` — **status only, no address** — so the existing `xce_request_with_retry()`
condition (`E_XCE_STANDBY` + populated redirect fields, `request.c:2010`) never matches a real
controller. Decisions (confirmed): classify on the actual statuses — `E_XCE_REDIRECT` →
fast-rotate the master list to find the existing primary (use an address if the reply carries
one, else rotate); `E_XCE_NOMASTER`/`E_XCE_NOT_READY` → backoff → cluster-not-ready;
`E_XCE_STANDBY` → rotate. The existing retry unit tests (keyed on the non-emitted shape) are
corrected to the real statuses. Tracked as bug-608.
**Scope expansion (confirmed):** the redirect address is absent today only because `join.c`
never fills the reply's `redirect_host/redirect_port` fields — those fields already exist in the
wire schema (`controller.capnp:568-569`) and the reply struct (`typemap.h:225-226`), and the
controller already knows the address (`join.c:263`). One small controller change to populate them
is now **in scope** (Requirement 8), making redirect-based failover deterministic (direct connect
to the primary, O(1)); client-side master-list rotation remains as a defensive fallback for an
older controller that omits the address.
## Constraints
- C library (libxce); follow project error-handling (`errnox`, early-return) and async-I/O
rules — `xce_request`/`xce_reply` non-blocking; never block the iomgr thread.
- TDD mandatory: failing tests in `src/lib/testsuite/` before implementation.
- Touches HA/failover behavior → full Kiro workflow (requirements → design → tasks), not the
quick path, per CLAUDE.md.
- Reuse the existing `xce_request_with_retry()` primitive and its config struct; do not fork a
second retry implementation.