8.8 KiB
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):
- Primary elected;
jobsub/joblistwork. - Kill the primary (
ctrl-1). - Before a new primary is elected,
joblistshould report cluster not ready. - 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-bootstrapRequirement 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 atsrc/lib/request.c:1956and unit-tested insrc/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_tdefaults: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) → plainxce_request()(request.c:1385). The same is true for ~18 otherxce_*_reqwrappers — none callxce_request_with_retry(). - Gap 2 — multi-master discovery: the retry primitive operates on a single
endpoint_tand only follows an explicitredirect_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 tomasters[0]once inxcecli_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 thanEINVAL. - 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:
- Give the request layer access to the cluster/master list + a "current primary" index
(design choice: a back-reference on
endpoint_t, or a smallxce_cluster_tthe endpoint references — resolved in design phase). - 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) orSTANDBY-without-redirect, reconnecting to each candidate to find the new primary; - apply the bounded backoff budget and, on expiry, surface
E_XCE_NOT_READY.
- rotate through the master list on connection failure (
- Route the production
xce_*_reqwrappers through the retry/failover path (e.g. makexce_request()the failover wrapper over an innerxce_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.cfor 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_testREPL 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_*_reqwrappers reach the failover path. - Error-surface mapping: connection/EINVAL vs.
E_XCE_NOT_READYvs.E_XCE_STANDBYvs. 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 codesE_XCE_NOT_READY/E_XCE_STANDBY+redirect_host/portreply 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-halive test harness.
Existing Spec Touchpoints
- Extends: consumes and completes the intent of
ha-config-bootstrapReq 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_replynon-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.