# Design Document — libxce-failover ## Overview **Purpose**: This feature delivers transparent controller-primary failover to every client application built on libxce (worker, scheduler, apiserver via cgo, and the `xce_test` harness), so that a controller failover is hidden when short and reported clearly when not. **Users**: libxce callers issue normal requests (`xce_joblist`, `xce_jobsub`, …). When the connected primary dies, libxce locates the newly elected primary and completes the request there, or — if no primary exists yet — returns a distinct cluster-not-ready error instead of an opaque `EINVAL`. No caller code changes. **Impact**: Today the client connects to one master (`client.c:122`), routes every request through the fire-and-forget `xce_request()`, and has no reply-status-driven retry on the production path (`xce_request_with_retry()` only retries in test-hook mode). This design places failover at the **`client.c` (`xcecli_*`) layer** — the universal client surface that every consumer goes through (worker, scheduler, apiserver-via-cgo, `xce_test`) and the only layer whose context (`api_ctx_t`) co-locates the master list (`conf`), the endpoint (`ep`), and the reply context (`ctx`). Each `xcecli_*` op already calls its internal `xce_*` op, which **returns the controller status**; the failover loop wraps that call, classifies the status, and reconnects/rotates over `api_ctx.conf`. It adds master-list rotation, redirect-address following, and a small controller change so redirect replies carry the primary address. `xceapi.c` ops are unchanged. ### Goals - Route all libxce request/reply round trips through one transparent failover path (1.1, 1.3). - Discover the new primary via redirect address, falling back to master-list rotation (2.x, 8.x). - Hide short failovers via bounded backoff; surface a distinct cluster-not-ready on exhaustion (3.x). - Fail over reads and writes uniformly, relying on controller idempotency for writes (5.x). - Reuse the existing retry config/defaults; stagger retries with jitter (6.x). ### Non-Goals - Controller-side election, WAL, commit barrier, write-idempotency logic (consumed, not changed). - Per-daemon adoption (master-list wiring, liveness-safe blocking) — `component-failover-adoption`. - New transport opcodes or new reply fields (redirect fields already exist). ## Boundary Commitments ### This Spec Owns - The client-side failover loop: classify status → reconnect/rotate/backoff → resume, including the cluster-not-ready terminal mapping, for **reply-bearing** `xcecli_*` ops. - Client-side master-list cursor and current-primary stickiness within a context. - Capturing the redirect address from a reply as a best-effort hint (no correctness dependency). - A **reconnect/rejoin hook**: a consumer-registered callback the failover machinery invokes after it reconnects to a new primary and *before* it resumes the interrupted op. libxce owns the hook mechanism, its ordering, and invoking it; it does **not** own the callback's content. - A per-ctx concurrency lock so `xcecli_*` failover is safe when one ctx is shared across threads. - The single controller change that populates `redirect_host/port` on `E_XCE_REDIRECT` replies. - Realignment of the libxce retry unit tests to the real controller status taxonomy. ### Out of Boundary - How the controller decides primary/standby/redirect/no-primary (election, `ha_should_redirect`). - The **content** of the rejoin callback: the join/state-exchange a daemon performs on reconnect (owned by `component-failover-adoption`) and the worker's running-jobs re-sync (owned by `worker-rejoin-resync`). This spec provides and invokes the hook; the daemons fill it in. - Redelivery of fire-and-forget notifications lost across a failover (also `worker-rejoin-resync`). - Worker/scheduler/apiserver adoption and their threading/liveness handling of bounded blocking. - Any reply payload beyond the already-defined `redirect_host/redirect_port`. ### Allowed Dependencies - Existing controller status codes (`errnox.h:22-25`) and redirect reply fields (`typemap.h:225`). - `endpoint_connect()` / endpoint I/O (`netio.*`), request encode/enqueue (`request.c`), and the context wait channel (`cbuf`, `xcectx.c`). - `xce_retry_config_t` + `XCE_RETRY_DEFAULTS` (request.h) and the cluster/peer config (`cluster.*`). ### Revalidation Triggers - Change to the reply `status`/`redirect_*` contract → re-check client classification + controller. - Change to the async reply-dispatch model (`handle_reply` → `cbuf` signalling) → re-check round trip. - Change to `api_ctx_t`/endpoint ownership → re-check reconnect-reuses-same-endpoint assumption. - New non-primary status code added to the controller → extend client classification. - Change to the reconnect/rejoin hook signature or its invocation ordering → re-check daemon rejoin registration (`component-failover-adoption`) and worker resync (`worker-rejoin-resync`). ## Architecture ### Existing Architecture Analysis - **Layering — `xcecli_*` is the universal surface.** Every consumer calls `client.c`'s `xcecli_*` ops, never `xceapi.c`'s `xce_*` directly: worker (`controller_connector.c:184,272`), scheduler (`scheduler.c:457,463`), apiserver via cgo (`clib.go:34,36`), `xce_test`. Each `xcecli_*` is a thin wrapper that calls its `xce_*` counterpart, which **returns the controller status** as its rc. - **Only `api_ctx_t` reaches the master list.** `api_ctx_t = { conf (masters), ep, ctx }` (client.c:23). The inner `xce_ctx_t` (xceapi) holds `ep`/`wbuf`/`state` but **no** `conf` (xcectx.h) — so failover belongs at the client layer, where the master list is already in scope. - **`xce_*` fail fast; `xcecli_*` retries.** The async send/wait stays inside `xce_*` unchanged: a single attempt that returns the controller status (or transport error). The `xcecli_*` wrapper classifies that rc and, on a retriable status, reconnects/rotates `api_ctx.ep` over `api_ctx.conf` and re-invokes `xce_*`. No async internals, thunks, or master-list plumbing are introduced. - **Reconnect reuses the endpoint.** `endpoint_connect(ep,host,port)` re-points the same `endpoint_t`, preserving the `msg_handler` wiring that routes replies to `ctx->wbuf`. - **Stateful consumers need a rejoin on reconnect.** `xce_test` is a pure client — reconnect + resend suffices. But the apiserver, worker, and scheduler hold a *session* with the primary established via `xcecli_join` (role + attrs); a freshly elected primary has no live session for a reconnecting daemon until it re-joins, and a worker must additionally re-sync its running-jobs state. So failover for these consumers is **reconnect → rejoin/state-exchange → resume**, not just reconnect → resend. libxce models this with a consumer-registered reconnect hook; the hook content lives in `component-failover-adoption` (join) and `worker-rejoin-resync` (job state). - **Two op shapes.** Reply-bearing ops (`xce_joblist`/`xce_jobsubmit`/`xce_hello`/…) do `xce_*_req` + `cbuf_get` → return a controller status the loop can classify. Fire-and-forget ops (`xce_notify*`, heartbeat) do `xce_*_req` only (no `cbuf_get`, xceapi.c:282) → return the enqueue result, never a controller status. Failover classification applies only to the former. - **Patterns preserved**: `xce_*` ops, `xce_*_req` enqueue wrappers, and `xce_request`/`xce_reply` are all unchanged; failover is additive in `xcecli_*`. Trade-off: ~33 `xcecli_*` wrappers each gain a small retry loop (more lines), but the policy lives in one shared decision helper (fewer moving parts) — an explicit simplicity-over-LOC choice. ### Architecture Pattern & Boundary Map ```mermaid graph LR Caller[consumer worker scheduler apiserver xce_test] --> Cli[xcecli op in client.c] Cli --> Loop[do while retry loop] Loop --> Decide[xcecli_retry_next decision helper] Loop --> Xce[xce op in xceapi one shot] Xce --> Ctrl[controller] Ctrl --> Reply[handle_reply] Reply --> Capture[capture redirect hint into ctx] Decide --> Rotate[reconnect or rotate over api_ctx conf and ep] Decide --> Backoff[jittered backoff] ``` - **Selected pattern**: a thin retry loop in each `xcecli_*` wrapper, driven by one shared decision helper (`xcecli_retry_next`). Rationale: `client.c` is the universal surface AND the only layer whose `api_ctx_t` co-locates master list + endpoint + ctx; `xce_*` already returns the status. - **Dependency direction**: `types → cluster/config → endpoint(netio) → xceapi(xce_*) → client(xcecli_*)`. Each layer imports only leftward. Failover logic is added at the `client` layer (rightmost), depending on `xce_*` and `cluster/config`; `xceapi` does not depend on it. ### Technology Stack | Layer | Choice / Version | Role in Feature | Notes | |-------|------------------|-----------------|-------| | Client wrappers | C (gnu17), libxce `client.c` | `xcecli_*` retry loop + decision helper + rotate | Reuses `xce_*`, `endpoint_connect`, `cluster` | | Client API | C (gnu17), libxce `xceapi.c` | **Unchanged** — one-shot `xce_*` returns status | No edits | | Controller | C (gnu17) | Populate redirect address on `E_XCE_REDIRECT` | ~2 lines in `join.c`; fields already in schema | | Tests | GoogleTest (testsuite) | Realign retry tests to real status taxonomy | Test the `xcecli_*` retry path | ## File Structure Plan ### New Files ``` src/lib/client_failover.c # xcecli_retry_next decision helper, StatusClassifier, rotate/reconnect over api_ctx src/lib/client_internal.h # private: api_ctx_t + xce_failover_state_t shared by client.c and client_failover.c ``` > `api_ctx_t` is currently file-local to `client.c`; move its definition to `client_internal.h` so the > failover helper can operate on it without exposing it in the public API. ### Modified Files - `src/lib/client.c` — wrap each **reply-bearing** `xcecli_*` op body in a `do { rc = xce_(...); } while (xcecli_retry_next(ac,&rc,is_write,&st));` loop (per OpCategory); fire-and-forget ops get the transport-reconnect-only variant; add `xce_failover_state_t` to `api_ctx_t` (lock + cursor + retry config + rejoin hook); add `xcecli_set_reconnect_handler()`; init + take `failover.lock` per op; init in `xcecli_start()`; keep initial connect to `masters[0]`. - `src/include/client.h` — declare `xcecli_set_reconnect_handler()` + `xcecli_reconnect_cb`. - `src/lib/xcectx.c` — `handle_reply()` copies the redirect-hint address (`reply.redirect_host/port`, when present) into `xce_ctx_t` before the reply is freed; the existing error path (`XCE_CTX_ERR` + `wbuf` error inject, xcectx.c:137-154) is the wake hook reused on reconnect. - `src/include/xcectx.h` — add the fixed-size redirect-hint `host/port` fields to `xce_ctx_t`. - `src/include/request.h` / `src/lib/request.c` — keep `xce_retry_config_t` + `XCE_RETRY_DEFAULTS`; **remove** the orphaned no-op `xce_request_with_retry()` (superseded by the `xcecli_*` loop). - `src/lib/xceapi.c` — **unchanged** (one-shot `xce_*` ops already return the controller status). - `src/controller/join.c` — populate `reply.redirect_host = c->ha.primary_host` / `reply.redirect_port = c->ha.primary_port` on the `E_XCE_REDIRECT` path (audit for other sites). - `src/lib/testsuite/test_libxce_retry.c` — retarget from the retired primitive to the `xcecli_*` retry path + `StatusClassifier`; cover `E_XCE_REDIRECT`/`NOMASTER`/`NOT_READY`/`STANDBY`, master rotation, and budget→not-ready. ## System Flows ### Failover loop (per `xcecli_*` op) ```mermaid sequenceDiagram participant C as Caller participant F as xcecli op do while loop participant E as Endpoint participant K as Controller C->>F: xcecli op loop until success, terminal, or budget exhausted F->>E: xce op one shot E->>K: request K-->>E: reply status plus optional redirect addr E-->>F: xce op returns status alt status success F-->>C: ok else redirect or standby or transport error F->>E: rotate (hint address first if set, else next master) then invoke rejoin hook else no-primary or not-ready F->>F: backoff jittered then rotate else non-retriable F-->>C: surface error end end F-->>C: cluster-not-ready on budget exhaustion ``` After any reconnect to a new endpoint, the failover machinery invokes the consumer-registered reconnect/rejoin hook (if set) **before** re-sending the interrupted op, so a stateful daemon re-establishes its session (and a worker re-syncs job state) against the new primary. Pure clients (`xce_test`) register no hook and skip straight to re-send. Key decisions: redirect/standby/transport rotate **without** the no-primary backoff delay (4.2); only no-primary/not-ready consume the backoff budget (3.1, 4.1); on success the served master becomes the sticky current primary (2.4). ### Status classification ```mermaid stateDiagram-v2 [*] --> Classify Classify --> Success: status==0 or DUP on re-sent write Classify --> RetryNow: REDIRECT or STANDBY or transport error Classify --> RetryBackoff: NOMASTER or NOT_READY Classify --> Terminal: AUTH PERM INVALID NOENT RetryNow --> Rotate RetryBackoff --> Rotate Rotate --> Classify: hint first else next master Success --> [*] Terminal --> [*] ``` ## Requirements Traceability | Requirement | Summary | Components | Flows | |-------------|---------|------------|-------| | 1.1, 1.3 | All ops through failover; preserve API | XcecliRetryLoop (all `xcecli_*`) | Failover loop | | 1.2 | Complete next request against new primary | XcecliRetryLoop, EndpointRotator | Failover loop | | 2.1 | Unreachable → try other masters | EndpointRotator | Classification | | 2.2, 8.3, 8.4 | Redirect addr if present else rotate | RedirectCapture, EndpointRotator | Classification | | 2.3, 4.3 | Standby → rotate | StatusClassifier, EndpointRotator | Classification | | 2.4 | Sticky current primary | FailoverState | Failover loop | | 2.5 | Redirect/standby/no-primary ≠ success | StatusClassifier | Classification | | 3.1, 4.1 | No-primary → backoff to budget | XcecliRetryLoop, FailoverState | Failover loop | | 3.2 | Success before budget | XcecliRetryLoop | Failover loop | | 3.3, 3.4 | Budget exhausted → distinct cluster-not-ready | XcecliRetryLoop | Failover loop | | 4.2 | Redirect → prompt switch, no backoff | EndpointRotator | Classification | | 4.4 | Transport → reconnect/rotate | EndpointRotator, ReplyWaitWake | Classification | | 4.5 | Non-retriable → surface | StatusClassifier | Classification | | 1.2 | Re-establish session against new primary | ReconnectRejoinHook | Failover loop | | 5.1, 5.3 | Reads+writes; no silent drop (reply-bearing) | XcecliRetryLoop, OpCategory | Failover loop | | 5.2 | Write retry not double-applied | StatusClassifier (DUP→success) + controller idempotency | — | | 6.1, 6.2, 6.3 | Configurable params, defaults, jitter | FailoverState, XcecliRetryLoop | Failover loop | | 7.1, 7.2 | Log failover target + exhaustion | XcecliRetryLoop | — | | 8.1, 8.2 | Controller populates redirect addr | ControllerRedirectAddress | — | ## Components and Interfaces | Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts | |-----------|-------|--------|--------------|------------------|-----------| | XcecliRetryLoop | client (`client.c`) | `do/while` wrapper around each reply-bearing `xce_*` call | 1.1,1.2,3.x,5.x,7.x | xcecli_retry_next(P0) | Service | | xcecli_retry_next | client (`client_failover.c`) | Decision helper: classify, rotate, rejoin, backoff, budget | 1.2,3.x,4.x,7.x | EndpointRotator(P0), StatusClassifier(P0), ReconnectRejoinHook(P0), FailoverState(P0) | Service | | ReconnectRejoinHook | client (`client.c`) | Invoke consumer callback after reconnect, before resume | 1.2 | api_ctx_t(P0) | Service, State | | OpCategory | client (`client.c`) | Per-op shape: reply-bearing / write / fire-and-forget | 1.1,5.1,5.2 | — | State | | EndpointRotator | client (`client_failover.c`) | Reconnect to addr or next master over api_ctx | 2.1,2.2,2.3,4.2,4.4 | endpoint_connect(P0), FailoverState(P0) | Service | | StatusClassifier | client (`client_failover.c`) | Map status → action | 2.5,4.1,4.2,4.3,4.5,5.2 | errnox codes(P0) | Service | | FailoverState | client (`api_ctx_t`) | Master cursor, retry cfg, sticky primary | 2.4,6.1 | xce_client_config_t(P0) | State | | RedirectCapture | ctx (`xcectx.c`) | Copy redirect-hint address into ctx (if present) | 2.2,8.3 | xce_reply_t(P0) | State | | ReplyWaitWake | ctx (`xcectx.c`) | Release cbuf_get on endpoint error | 4.4 | endpoint err_handler(P0) | Service | | ControllerRedirectAddress | controller | Fill redirect_host/port | 8.1,8.2 | ha_state primary_host/port(P0) | State | ### client (libxce) #### XcecliRetryLoop — the wrapper pattern in each `xcecli_*` | Field | Detail | |-------|--------| | Intent | Turn each one-shot `xce_*` call into a transparent failover loop with no change to `xce_*` | | Requirements | 1.1, 1.2, 3.1, 3.2, 3.3, 3.4, 5.1, 5.3, 7.1, 7.2 | **Responsibilities & Constraints** - Each `xcecli_*` op runs its existing `xce_*` call inside a `do/while` driven by `xcecli_retry_next`. - `xce_*` ops are unchanged: each is a single attempt that returns the controller status (fail fast). - Never drops a request silently (5.3): the loop returns the op's success value or a distinct error. **Contracts**: Service [x] ```c /* Pattern applied to every xcecli_* op (joblist shown; is_write=false for reads). */ int xcecli_joblist(xcecli_ctx_t c, xce_job_t ***out) { api_ctx_t *ac = (api_ctx_t *)c; xcecli_retry_t st = XCECLI_RETRY_INIT(ac); /* attempt=0, delay/seed from ac->failover.retry */ int rc; do { rc = xce_joblist(ac->ctx, out); /* unchanged one-shot xceapi op */ } while (xcecli_retry_next(ac, &rc, /*is_write=*/false, &st)); return rc; /* >=0 ok; -E_XCE_NOT_READY = cluster not ready */ } ``` - Postconditions: `>=0` success (sticky primary updated, 2.4); `-E_XCE_NOT_READY` on no-primary budget exhaustion (3.3); terminal errors surfaced as-is (4.5). For writes, pass `is_write=true`. #### xcecli_retry_next — decision helper | Field | Detail | |-------|--------| | Intent | Decide whether to retry after an `xce_*` returned `rc`; perform reconnect/rotate/backoff | | Requirements | 1.2, 3.1, 3.3, 3.4, 4.1, 4.2, 4.3, 4.4, 5.2, 7.1, 7.2 | **Responsibilities & Constraints** - Owns the retry budget, backoff progression, sticky-primary update, and cluster-not-ready mapping. - After a transport error the existing path left `ctx->state = XCE_CTX_ERR`; on a successful reconnect this helper restores `XCE_CTX_RUN` so the next `xce_*` passes its guard (CI2). - Drains any stale entry from `ctx->wbuf` after reconnect, before the next attempt (CI1); reconnect closes the old fd first, so the dead connection yields no further replies. **Contracts**: Service [x] ```c typedef struct { int attempt; int delay_ms; unsigned int rand_seed; } xcecli_retry_t; /* Returns true → caller re-invokes its xce_* op; false → stop, *rc is final. * On DUP write replay it sets *rc = 0 (success) and returns false (5.2). */ bool xcecli_retry_next(api_ctx_t *ac, int *rc, bool is_write, xcecli_retry_t *st); ``` - Decisions: SUCCESS/TERMINAL → return false; RETRY_NOW → `EndpointRotator` (hint-first) then true, no backoff (4.2); RETRY_BACKOFF → jittered sleep then `EndpointRotator` then true (4.1); attempts beyond `retry.max_retries` while no-primary → set `*rc = -E_XCE_NOT_READY`, return false (3.3, 3.4). - Validation: INFO log target on rotation (7.1); WARN log attempt count on exhaustion (7.2). #### EndpointRotator | Field | Detail | |-------|--------| | Intent | Re-point `ac->ep` to a redirect address or the next master in `ac->conf` | | Requirements | 2.1, 2.2, 2.3, 4.2, 4.4, 8.4 | **Responsibilities & Constraints** - On retry, if a redirect-address **hint** was captured, try it first (then clear it); otherwise advance the master cursor and reconnect to the next configured master (8.3, 8.4). A failed hint just falls through to rotation on the next retry — correctness never depends on it. - Reuse the same `endpoint_t` (reply routing to `ctx->wbuf` preserved); close stale fd/ssl first. **Contracts**: Service [x] ```c /* Reconnect ac->ep to the redirect-hint address if one is set (consumes it), else advance to the * next configured master in ac->conf and reconnect. Returns 0 or -errno. */ int xce_failover_rotate(api_ctx_t *ac); ``` #### StatusClassifier | Field | Detail | |-------|--------| | Intent | Map a reply status / transport rc to a failover action | | Requirements | 2.5, 4.1, 4.2, 4.3, 4.5, 5.2 | **Contracts**: Service [x] ```c typedef enum { XCE_FO_SUCCESS, /* status == 0, or idempotent-replay success (DUP on a re-sent write) */ XCE_FO_RETRY_NOW, /* E_XCE_REDIRECT / E_XCE_STANDBY / transport error — rotate, no backoff */ XCE_FO_RETRY_BACKOFF, /* E_XCE_NOMASTER / E_XCE_NOT_READY — rotate after jittered backoff */ XCE_FO_TERMINAL /* AUTH/PERM/INVALID/NOENT and other non-retriable — surface */ } xce_fo_action_t; /* attempt == 0 for the first send; >0 for a re-send after failover. * is_write distinguishes mutating ops (job submit) from reads. * The redirect ADDRESS is NOT a classifier concern — it is a hint consumed by EndpointRotator. */ xce_fo_action_t xce_failover_classify(int status, bool is_write, int attempt); ``` - Invariants: `E_XCE_REDIRECT`/`STANDBY`/`NOMASTER`/`NOT_READY` are never reported as success (2.5). - **Idempotent-replay rule (5.2):** `-E_XCE_DUP_JOB` / `-EEXIST` on a **re-sent write** (`is_write && attempt > 0`) maps to `XCE_FO_SUCCESS` — the controller's client-supplied-`jobid` replay guard (`job.c:56,95`) means "already applied" = our write succeeded. On `attempt == 0` the same status is a genuine duplicate and maps to `XCE_FO_TERMINAL` (surfaced unchanged). #### OpCategory — per-op shape (CI1) | Field | Detail | |-------|--------| | Intent | Classify each `xcecli_*` op so its wrapper applies the right failover behavior | | Requirements | 1.1, 5.1, 5.2 | Three categories drive the wrapper generated for each of the ~33 ops: | Category | Ops (examples) | `xce_*` shape | Failover behavior | |----------|----------------|---------------|-------------------| | Reply-read | `joblist`, `reslist`, `workerlist`, `clusterconf`, `resval`, `hello`, `jobinfo` | `*_req` + `cbuf_get` → status | Full failover; `is_write=false` | | Reply-write | `jobsubmit`, `jobctrl`, `queuectrl`, `hostctrl`, `update_resvals`, `runtask`, `join` | `*_req` + `cbuf_get` → status | Full failover; `is_write=true` (DUP-replay→success) | | Fire-and-forget | `notify_*` (jobstart/finish/fail/stop/chkpt), `heartbeat`, `wakeup`, `nop` | `*_req` only, **no status** | Best-effort: reconnect on a known-dead connection only; **no** standby/redirect detection, **no** no-primary backoff. Redelivery deferred to `worker-rejoin-resync` | - The exact per-op assignment is enumerated during task generation; `join` is reply-write and is the op that triggers `E_XCE_REDIRECT` at reconnect time (it carries the daemon's role/attrs). #### ReconnectRejoinHook | Field | Detail | |-------|--------| | Intent | Let a stateful consumer re-establish its session on the new primary after a reconnect, before the interrupted op resumes | | Requirements | 1.2 | **Responsibilities & Constraints** - libxce owns the hook *mechanism* and *ordering* (reconnect → hook → resume); the callback *content* (join handshake, worker job re-sync) belongs to `component-failover-adoption` / `worker-rejoin-resync`. - Invoked once per successful reconnect to a *new* endpoint, before the next `xce_*` attempt. If the hook fails (e.g. the new primary rejects the join), the failover loop treats it like a failed attempt (rotate/backoff per the returned status). Pure clients register no hook (no-op path). **Contracts**: Service [x] / State [x] ```c /* Registered via the client API; cb runs on the calling thread after reconnect. * Return 0 to proceed with the interrupted op; <0 to treat the rejoin as failed. */ typedef int (*xcecli_reconnect_cb)(xcecli_ctx_t ctx, void *arg); int xcecli_set_reconnect_handler(xcecli_ctx_t ctx, xcecli_reconnect_cb cb, void *arg); ``` ### client / ctx (libxce) #### FailoverState (on `api_ctx_t`) **Contracts**: State [x] ```c /* On api_ctx_t — persists across calls (sticky primary + tunables). */ typedef struct { mylock_t lock; /* serializes xcecli_* per ctx (CI2 — shared-ctx safety) */ int cur_master; /* index into conf->masters[] (sticky primary) */ xce_retry_config_t retry; /* defaults from XCE_RETRY_DEFAULTS unless overridden */ xcecli_reconnect_cb rejoin_cb; /* consumer rejoin hook; NULL for pure clients */ void *rejoin_arg; } xce_failover_state_t; /* On xce_ctx_t — written by handle_reply (which only sees xce_ctx_t), read by the rotator via ac->ctx. */ /* char redirect_hint_host[XCE_MAXHOSTNAME_LEN]; hint from last redirect reply; "" if none */ /* uint16_t redirect_hint_port; */ ``` - State model: `cur_master` updated on a successful served-as-primary reply (2.4); retry config from `XCE_RETRY_DEFAULTS` unless overridden (6.1, 6.2). The redirect hint is read by the rotator on the next retry and cleared on consumption; it is purely advisory. - **Concurrency (CI2):** the apiserver shares one ctx across goroutines (a heartbeat goroutine plus request handlers, `clib.go:182`). Each `xcecli_*` op takes `failover.lock` for the duration of its `do/while` loop, so reconnect/rotate (which closes `ac->ep` and drains `wbuf`) cannot race another in-flight call on the same ctx. The single-slot `wbuf` already implies one in-flight request per ctx; the lock makes that explicit and covers the reconnect window. #### RedirectCapture (in `handle_reply`) **Contracts**: State [x] - On a redirect reply that carries an address, copy `reply.redirect_host` (capnp-owned, freed with the reply — see request.c:243-250 hazard) + `redirect_port` into the fixed-size `xce_ctx_t` hint fields before the reply is freed; leave the hint empty otherwise. Because the hint is advisory, the copy needs no sequence correlation or lifetime guarantees beyond "copy now, use-or-discard next retry." The status continues through the existing `cbuf_tryput(&ctx->wbuf,&status)` path (xcectx.c:303), so `xceapi.c` is unchanged. #### ReplyWaitWake (endpoint error path) **Contracts**: Service [x] - On endpoint error/RDHUP for `ctx->ep`, release a pending `cbuf_get` with a transport error (`-ECONNRESET`/`-ETIMEDOUT`) so the `xce_*` call returns and the loop rotates instead of hanging (4.4, F7). The existing error path already injects an error into `wbuf` and sets `ctx->state = XCE_CTX_ERR` (xcectx.c:148-154) — this component reuses it. - **State reset (CI2):** that error path leaves `ctx->state = XCE_CTX_ERR`, which the per-op guard rejects as `-EINVAL` (xceapi.c:14,38,…) — the reported bug. `xcecli_retry_next` restores `ctx->state = XCE_CTX_RUN` after a successful reconnect, before the next `xce_*` attempt, so the guard (re-checked per attempt) passes. **CI1 drain:** the helper also drains any stale `wbuf` entry after reconnect so a late reply from the dead connection is never consumed by the next attempt. ### controller #### ControllerRedirectAddress (`join.c`) **Contracts**: State [x] - On the `E_XCE_REDIRECT` path, set `reply.redirect_host = c->ha.primary_host` and `reply.redirect_port = c->ha.primary_port` (8.1). When the primary is unknown, keep returning `E_XCE_NOMASTER` rather than an addressless redirect (8.2 — already the case at join.c:270). - Audit during implementation: confirm `join.c` is the sole `E_XCE_REDIRECT` emitter; apply the same populate to any other site found. ## Error Handling ### Error Strategy Classify every round-trip outcome (StatusClassifier) into success / rotate-now / backoff / terminal, and bound the loop by the retry budget. Repair the context on transport failure rather than leaving it in a non-RUN state (F7) so failover is repeatable. **Context state model (CI2):** the existing transport-error path sets `ctx->state = XCE_CTX_ERR` (xcectx.c:148); the per-op `XCE_CTX_RUN` guard then rejects with `-EINVAL` (xceapi.c:14,…) — the reported bug. `xcecli_retry_next` owns the transition back: after a successful reconnect it sets `ctx->state = XCE_CTX_RUN` before the next `xce_*` attempt, so the per-op guard (re-checked each attempt) passes. The guard otherwise stays exactly as today — `xceapi.c` is unchanged. **Stale-reply avoidance (CI1):** `ctx->wbuf` is a single-slot, status-only channel (`cbuf_init(...,1,sizeof(int))`, xcectx.c:185). Reconnect closes the old fd **first**, so the dead connection produces no further replies; `xcecli_retry_next` then drains any entry already sitting in `wbuf` before the next attempt. This drain-after-close is sufficient — no per-reply sequence correlation is needed (a deliberate simplification). ### Error Categories and Responses - **Transport (refused/timeout/reset)**: reconnect/rotate, retry; wake any pending wait (4.4). - **No primary (NOMASTER/NOT_READY)**: jittered backoff; on budget exhaustion return the distinct `-E_XCE_NOT_READY` cluster-not-ready code (3.3, 3.4) — never `-EINVAL`. - **Redirect/standby**: rotate (address-first) without backoff (4.2, 4.3). - **Terminal (AUTH/PERM/INVALID/NOENT)**: surface immediately, unchanged (4.5). ### Monitoring INFO log on each rotation with the target endpoint (7.1); WARN log on budget exhaustion with the attempt count and last status (7.2). Reuse existing libxce logging. ## Performance & Scalability Rejoin-on-failover cost is **accepted as-is** (decision 2026-06-16): failover is a rare, exceptional event, so the cost is not on any hot path, and *some* handshake is unavoidable — a freshly elected primary has no live connection to a reconnecting daemon until it re-joins. Characteristics: - Scheduler/apiserver join carry trivial payloads (`NULL` attrs); worker join carries a small `hostattrs_t`. All reuse the daemons' existing startup join path — no new heavy machinery. - On rejoin, apiserver/scheduler receive **full** state (e.g. the full job list), not a delta; for a large cluster with many workers this is sizable but infrequent. This is simpler and correct, and still cheaper than a full-history replace with no snapshot (cf. IBM LSF). - **Herd control**: the jittered backoff (6.3) staggers simultaneous reconnects after a failover. **Deferred optimization (YAGNI):** a lightweight "re-attach" (session-token hello letting the primary skip re-pushing already-replicated state, or delta-only state transfer) is intentionally **not** designed now — delta reconciliation is non-trivial and full-state-on-rejoin is acceptable. Revisit only if failover-rejoin latency is *measured* as a problem at scale. Recorded in `roadmap.md` backlog. ## Testing Strategy ### Unit Tests (`test_libxce_retry.c`, realigned) - `xce_failover_classify`: REDIRECT+addr→ROTATE_ADDR; REDIRECT/STANDBY no-addr→ROTATE; NOMASTER/NOT_READY→BACKOFF; AUTH/PERM/INVALID/NOENT→TERMINAL; 0→SUCCESS (2.5, 4.1-4.5). - `xcecli_retry_next` loop: NOMASTER until budget → returns `-E_XCE_NOT_READY` (3.3); becomes primary mid-loop → success, sticky cursor advanced (2.4, 3.2); non-retriable → immediate surface (4.5). - Stale-reply avoidance (CI1): after a reconnect (old fd closed first), a stale entry left in `wbuf` is drained before the next attempt, so no prior-attempt status is consumed; no sequence matching (1.2, 5.3). - Context state (CI2): after a transport error drives `XCE_CTX_ERR`, a successful reconnect restores `XCE_CTX_RUN` and the retry completes — never surfaces `-EINVAL` (1.2, 3.2). - Idempotent write (CI3): `DUP_JOB` on a re-sent `jobsub` (attempt>0) → success; on attempt 0 → surfaced as duplicate (5.2). - EndpointRotator: redirect address used first (8.3); absent → next master (8.4); rotation reuses same endpoint object. - RetryConfig: NULL cfg → defaults (6.2); jitter staggers concurrent retries (6.3, reuse 50-client). ### Integration / E2E (3-node, `/xce-deploy-ha` + `xce_test`) - Submit jobs, kill primary, `joblist`: returns cluster-not-ready during the election window, then transparently succeeds against the new primary with no error (1.2, 3.2, 3.3) — the bug reproduction. - Redirect path: contact a standby directly → client lands on primary in one hop via the populated redirect address (8.1, 8.3). - Write failover: `jobsub` across a failover → exactly one job created (5.1, 5.2 via controller idempotency). ### Controller Unit Test - `handle_join` redirect path populates `redirect_host/port` from `ha.primary_host/port` (8.1); unknown primary still yields `E_XCE_NOMASTER` (8.2).