32 KiB
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/portonE_XCE_REDIRECTreplies. - 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 byworker-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→cbufsignalling) → 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 callsclient.c'sxcecli_*ops, neverxceapi.c'sxce_*directly: worker (controller_connector.c:184,272), scheduler (scheduler.c:457,463), apiserver via cgo (clib.go:34,36),xce_test. Eachxcecli_*is a thin wrapper that calls itsxce_*counterpart, which returns the controller status as its rc. - Only
api_ctx_treaches the master list.api_ctx_t = { conf (masters), ep, ctx }(client.c:23). The innerxce_ctx_t(xceapi) holdsep/wbuf/statebut noconf(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 insidexce_*unchanged: a single attempt that returns the controller status (or transport error). Thexcecli_*wrapper classifies that rc and, on a retriable status, reconnects/rotatesapi_ctx.epoverapi_ctx.confand re-invokesxce_*. No async internals, thunks, or master-list plumbing are introduced.- Reconnect reuses the endpoint.
endpoint_connect(ep,host,port)re-points the sameendpoint_t, preserving themsg_handlerwiring that routes replies toctx->wbuf. - Stateful consumers need a rejoin on reconnect.
xce_testis a pure client — reconnect + resend suffices. But the apiserver, worker, and scheduler hold a session with the primary established viaxcecli_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 incomponent-failover-adoption(join) andworker-rejoin-resync(job state). - Two op shapes. Reply-bearing ops (
xce_joblist/xce_jobsubmit/xce_hello/…) doxce_*_req+cbuf_get→ return a controller status the loop can classify. Fire-and-forget ops (xce_notify*, heartbeat) doxce_*_reqonly (nocbuf_get, xceapi.c:282) → return the enqueue result, never a controller status. Failover classification applies only to the former. - Patterns preserved:
xce_*ops,xce_*_reqenqueue wrappers, andxce_request/xce_replyare all unchanged; failover is additive inxcecli_*. Trade-off: ~33xcecli_*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
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.cis the universal surface AND the only layer whoseapi_ctx_tco-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 theclientlayer (rightmost), depending onxce_*andcluster/config;xceapidoes 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_tis currently file-local toclient.c; move its definition toclient_internal.hso the failover helper can operate on it without exposing it in the public API.
Modified Files
src/lib/client.c— wrap each reply-bearingxcecli_*op body in ado { rc = xce_<op>(...); } while (xcecli_retry_next(ac,&rc,is_write,&st));loop (per OpCategory); fire-and-forget ops get the transport-reconnect-only variant; addxce_failover_state_ttoapi_ctx_t(lock + cursor + retry config + rejoin hook); addxcecli_set_reconnect_handler(); init + takefailover.lockper op; init inxcecli_start(); keep initial connect tomasters[0].src/include/client.h— declarexcecli_set_reconnect_handler()+xcecli_reconnect_cb.src/lib/xcectx.c—handle_reply()copies the redirect-hint address (reply.redirect_host/port, when present) intoxce_ctx_tbefore the reply is freed; the existing error path (XCE_CTX_ERR+wbuferror inject, xcectx.c:137-154) is the wake hook reused on reconnect.src/include/xcectx.h— add the fixed-size redirect-hinthost/portfields toxce_ctx_t.src/include/request.h/src/lib/request.c— keepxce_retry_config_t+XCE_RETRY_DEFAULTS; remove the orphaned no-opxce_request_with_retry()(superseded by thexcecli_*loop).src/lib/xceapi.c— unchanged (one-shotxce_*ops already return the controller status).src/controller/join.c— populatereply.redirect_host = c->ha.primary_host/reply.redirect_port = c->ha.primary_porton theE_XCE_REDIRECTpath (audit for other sites).src/lib/testsuite/test_libxce_retry.c— retarget from the retired primitive to thexcecli_*retry path +StatusClassifier; coverE_XCE_REDIRECT/NOMASTER/NOT_READY/STANDBY, master rotation, and budget→not-ready.
System Flows
Failover loop (per xcecli_* op)
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
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 existingxce_*call inside ado/whiledriven byxcecli_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]
/* 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:
>=0success (sticky primary updated, 2.4);-E_XCE_NOT_READYon no-primary budget exhaustion (3.3); terminal errors surfaced as-is (4.5). For writes, passis_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 restoresXCE_CTX_RUNso the nextxce_*passes its guard (CI2). - Drains any stale entry from
ctx->wbufafter reconnect, before the next attempt (CI1); reconnect closes the old fd first, so the dead connection yields no further replies.
Contracts: Service [x]
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 thenEndpointRotatorthen true (4.1); attempts beyondretry.max_retrieswhile 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 toctx->wbufpreserved); close stale fd/ssl first.
Contracts: Service [x]
/* 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]
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_READYare never reported as success (2.5). - Idempotent-replay rule (5.2):
-E_XCE_DUP_JOB/-EEXISTon a re-sent write (is_write && attempt > 0) maps toXCE_FO_SUCCESS— the controller's client-supplied-jobidreplay guard (job.c:56,95) means "already applied" = our write succeeded. Onattempt == 0the same status is a genuine duplicate and maps toXCE_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;
joinis reply-write and is the op that triggersE_XCE_REDIRECTat 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]
/* 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]
/* 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_masterupdated on a successful served-as-primary reply (2.4); retry config fromXCE_RETRY_DEFAULTSunless 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). Eachxcecli_*op takesfailover.lockfor the duration of itsdo/whileloop, so reconnect/rotate (which closesac->epand drainswbuf) cannot race another in-flight call on the same ctx. The single-slotwbufalready 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_portinto the fixed-sizexce_ctx_thint 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 existingcbuf_tryput(&ctx->wbuf,&status)path (xcectx.c:303), soxceapi.cis unchanged.
ReplyWaitWake (endpoint error path)
Contracts: Service [x]
- On endpoint error/RDHUP for
ctx->ep, release a pendingcbuf_getwith a transport error (-ECONNRESET/-ETIMEDOUT) so thexce_*call returns and the loop rotates instead of hanging (4.4, F7). The existing error path already injects an error intowbufand setsctx->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_nextrestoresctx->state = XCE_CTX_RUNafter a successful reconnect, before the nextxce_*attempt, so the guard (re-checked per attempt) passes. CI1 drain: the helper also drains any stalewbufentry 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_REDIRECTpath, setreply.redirect_host = c->ha.primary_hostandreply.redirect_port = c->ha.primary_port(8.1). When the primary is unknown, keep returningE_XCE_NOMASTERrather than an addressless redirect (8.2 — already the case at join.c:270). - Audit during implementation: confirm
join.cis the soleE_XCE_REDIRECTemitter; 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_READYcluster-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 (
NULLattrs); worker join carries a smallhostattrs_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_nextloop: 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
wbufis 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 restoresXCE_CTX_RUNand the retry completes — never surfaces-EINVAL(1.2, 3.2). - Idempotent write (CI3):
DUP_JOBon a re-sentjobsub(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:
jobsubacross a failover → exactly one job created (5.1, 5.2 via controller idempotency).
Controller Unit Test
handle_joinredirect path populatesredirect_host/portfromha.primary_host/port(8.1); unknown primary still yieldsE_XCE_NOMASTER(8.2).