540 lines
32 KiB
Markdown
540 lines
32 KiB
Markdown
# 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_<op>(...); } 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).
|