40 KiB
Design Document: component-failover-adoption
Overview
Scheduler, worker, and apiserver each connect to the controller cluster today using only a single configured endpoint at a time, with divergent, partly-broken hand-rolled logic for trying alternates. Meanwhile libxce already ships a complete, tested, transparent multi-master mechanism (XCECLI_FAILOVER_CALL and friends in src/lib/client.c) — the same one xce_test's masters setting already uses correctly. This design wires all three components onto that existing mechanism: populate the full candidate list once, let the library rotate/retry/reconnect on every call, and register a rejoin callback so a component re-establishes cluster membership after a transparent reconnect.
Users: HA cluster operators who configure multiple controller candidates (--peers for scheduler/worker, backend.peers YAML for apiserver) and expect scheduler, worker, and apiserver to survive a controller failover without manual restarts — the same expectation xce_test already meets.
Impact: Removes worker's and scheduler's hand-rolled per-candidate join loops (deleting, not adding, most of the connection-retry code in controller_connector.c and scheduler.c); replaces apiserver's Go-level per-peer loop with a single library-driven connect; adds one new small shared C helper and a small Go-side health guard. No changes to the controller or to libxce's failover mechanism itself.
Goals
- Scheduler, worker, and apiserver each hand the full configured candidate list to
xce_client_config_ton the very first connection attempt (Req 1). - All three automatically reconnect and resume cluster membership after their controller connection is lost, without a process restart (Req 2).
- Worker's and scheduler's retry/backoff never blocks duties independent of controller connectivity (Req 3).
- apiserver returns a clear, fast error to its own API clients while its backend connection is down, instead of hanging (Req 4).
- Every connect/reconnect attempt and outcome is logged per-candidate (Req 5).
- apiserver accepts the identical candidate-list syntax scheduler/worker already accept (Req 6).
Non-Goals
- Changing
xce_test(it is already the reference-correct client). - Completing or adopting the separate, unused
xce_connect_cluster()/xce_reconnect_*primitive incluster.c— superseded by the mechanism this design adopts; left untouched. - Raising the
N_MAX_MASTERS(3) cap or reconciling it withMAX_HA_CANDIDATES(4) — pre-existing constraint, replicated (clamp + WARN) exactly asxce_testalready does. - Wiring CA-cert/TLS trust into the real connect path —
xce_client_config_tcarries no such field today and this feature does not add one; TLS is established the same way it already is. - Controller-side changes (redirect-reply population, peer authentication) — already shipped by
libxce-failover. - Recovering worker job-state lost during a disconnect window — owned by
worker-rejoin-resync.
Boundary Commitments
This Spec Owns
- A new shared libxce helper that converts a validated
xce_cluster_config_t(parsed candidate list) into a fully-populatedxce_client_config_t. - Worker's, scheduler's, and apiserver's integration code that calls
xcecli_ctx_init/xcecli_start/xcecli_joinwith the full candidate list and registers a rejoin callback. - apiserver's candidate-list config parsing (routed through the existing C parser via CGO instead of a second Go implementation).
- apiserver's client-facing fail-fast behavior while its backend connection is degraded.
- Connect/reconnect diagnostic logging in all three components.
Out of Boundary
xce_testand itsmasterssetting (unchanged, reference implementation).libxce's failover mechanism itself (client.c,client_internal.h,client_failover.c) — consumed as-is, not modified.xce_connect_cluster()/xce_reconnect_*(unused, untouched).- Controller-side membership/redirect logic.
- Worker job-state resync content on rejoin (
worker-rejoin-resync). N_MAX_MASTERS/MAX_HA_CANDIDATESvalue changes.- TLS/CA-cert plumbing into the real connect path.
Allowed Dependencies
src/lib/client.c/client_internal.h/client_failover.h(shippedlibxce-failovermechanism) — read-only dependency, no modification.src/lib/cluster.c/xce/cluster.h(xce_parse_peers,xce_cluster_config_validate) — existing, unmodified; this spec adds one new function alongside them.- Existing per-component candidate-list CLI/YAML surfaces (
--peers,--ca-cert,backend.peers) — unmodified externally; only how they're consumed changes.
Revalidation Triggers
- Any change to
xce_client_config_t's layout orN_MAX_MASTERS. - Any change to
XCECLI_FAILOVER_CALL/XCECLI_FF_CALLretry/classification semantics (libxce-failover). - Any change to
xce_parse_peers/xce_cluster_config_validatesyntax or validation rules (apiserver's CGO-based parser would need re-verification). worker-rejoin-resynclanding — worker's rejoin callback becomes the natural place to also trigger job-state resync; this spec's callback is a no-op-beyond-rejoin placeholder for that.
Architecture
Existing Architecture Analysis
Today each of the three components independently drives its own xcecli_ctx_t lifecycle with nmasters hardcoded to 1 per connection attempt, and layers its own outer candidate loop on top:
- worker:
controller_connector.c'sxcecd_join_clusterloops candidates itself, but the very first connection (insidexcecd_start()) happens beforecc_ctx->cluster_cfgis even populated (main.cassigns it afterxcecd_start()returns) — the candidate list is silently ignored on startup. - scheduler:
scheduler.c'ssched_initloops candidates correctly at startup, but there is no reconnect logic anywhere afterward. - apiserver:
clib.go'sRegisterToBackendloops candidates once via a hand-written Go parser, only at process startup.
None of the three ever set nmasters > 1 on a single xce_client_config_t, so the library's own rotation/retry (client.c, wrapped by XCECLI_FAILOVER_CALL) never actually engages for them.
Architecture Pattern & Boundary Map
graph TB
subgraph Daemons
Worker[worker xcecd]
Scheduler[xce_scheduler]
Apiserver[apiserver backend package]
end
subgraph NewSharedCode
Convert[xce_cluster_config_to_client_config]
end
subgraph ExistingLibxce
Parse[xce_parse_peers]
Validate[xce_cluster_config_validate]
CtxInit[xcecli_ctx_init xcecli_start xcecli_join]
Failover[XCECLI_FAILOVER_CALL rotate retry backoff]
Rejoin[rejoin callback xcecli_set_reconnect_handler]
end
subgraph Cluster
Primary[Controller Primary]
Standby1[Controller Standby]
end
Worker --> Parse
Scheduler --> Parse
Apiserver --> Parse
Parse --> Validate
Validate --> Convert
Convert --> CtxInit
CtxInit --> Failover
Failover --> Rejoin
Failover --> Primary
Failover --> Standby1
Architecture Integration:
- Selected pattern: thin per-component integration layer over an unchanged, already-shipped library mechanism (adapter, not new subsystem).
- Domain boundaries:
libxceowns candidate parsing, validation, connect/rotate/retry/backoff, and rejoin-hook invocation (unchanged). Each daemon owns only: building the candidate list from its own config surface, calling the library once, and supplying its role-specific rejoin behavior (what to send when re-joining). - Existing patterns preserved: the
xce_parse_peers/xce_cluster_config_validate/xce_client_config_tsplit; the CGO bridge pattern already used throughoutbackend/; worker's dedicated connector thread. - New components: one C helper (candidate-list → client-config conversion, generalized out of
xce_test's inline logic) and one small Go health-state module (apiserver-only, has no C-side equivalent need). - Steering compliance: no new blocking work introduced on iomgr/delaymgr-equivalent threads; worker's existing thread isolation is preserved, not altered.
Rejoin callback constraint: avoiding self-deadlock on ac->failover.lock
Verified directly against client_failover.c:341-411: XCECLI_FAILOVER_CALL holds ac->failover.lock (a plain, non-recursive pthread_mutex_t — mylock_init calls pthread_mutex_init(l, NULL), no PTHREAD_MUTEX_RECURSIVE attribute) for the entire retry loop, and xcecli_invoke_rejoin() — which calls the registered rejoin callback — is invoked synchronously from inside that loop, while the lock is held (client_failover.c:397). Any rejoin callback that itself calls a public xcecli_* wrapper on the same xcecli_ctx_t (e.g. xcecli_join, which is itself XCECLI_FAILOVER_CALL-wrapped and tries to re-lock the same mutex) self-deadlocks — the calling thread blocks forever on its own already-held lock. This was never caught because xcecli_set_reconnect_handler has zero production callers today and the existing unit test's callback is a no-op probe that never calls back into the client.
This directly constrains every component's rejoin implementation in this spec:
- The callback registered with the library (worker's
checking_connection→xcecli_hellopath, scheduler'sxcecli_jobdispatchpath, apiserver's write-call path) must do no I/O and call noxcecli_*function — it may only set a lightweight "needs rejoin" flag (a plain bool/atomic on the consumer side) and return0so the interrupted operation resumes normally. - Each component's own calling code, after the triggering
xcecli_*call returns (at which point the library has released the lock), checks and clears that flag and issuesxcecli_join(...)as a separate, subsequent call — safe, since the lock is free by then. - apiserver's heartbeat-triggered path (see below) is unaffected by this constraint — it already detects recovery and calls the rejoin logic after
xcecli_heartbeathas returned (XCECLI_FF_CALLalso releases its lock before returning), so it was never at risk.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Shared library | C (gnu17), src/lib/cluster.c |
New candidate→client-config conversion helper | Extends existing file alongside xce_parse_peers |
| Worker / Scheduler | C (gnu17) | Consume the existing xcecli_ctx_t mechanism directly |
No new dependency |
| apiserver | Go 1.23.4 + CGO | Consume the same mechanism via the existing CGO bridge; new Go-side health-state guard | sync/atomic, consistent with existing heartbeat.go pattern |
File Structure Plan
Modified / New Files
src/include/xce/cluster.h # + declare xce_cluster_config_to_client_config()
src/lib/cluster.c # + implement xce_cluster_config_to_client_config()
src/lib/testsuite/test_cluster.c # + unit tests for the new helper
src/worker/src/controller_connector.c # rewrite: delete per-candidate loop in
# xcecd_join_cluster/init_client_config;
# single init+start+join using full
# candidate list; register rejoin callback
src/worker/src/main.c # fix: populate cc_ctx->cluster_cfg BEFORE
# xcecd_start() (removes init-order bug)
src/worker/include/controller_connector.h # + rejoin callback function declaration
src/scheduler/src/scheduler.c # rewrite: sched_init uses the new helper +
# single init+start+join; delete inline
# per-candidate loop; register rejoin callback
src/scheduler/src/main.c # no ordering change needed (already correct);
# simplified call site only
src/frontend/apiserver/backend/clib.go # rewrite RegisterToBackend: single connect
# using the full candidate list via CGO calls
# into xce_parse_peers/xce_cluster_config_validate/
# xce_cluster_config_to_client_config; cgo
# preamble gains a static C shim (mirrors the
# existing xce_backend_handler/go_push_handler
# pattern) that calls the reconnect.go export,
# passed to xcecli_set_reconnect_handler; the
# heartbeat tick callback explicitly re-invokes
# the same rejoin path on recovery (see below)
src/frontend/apiserver/backend/health.go # NEW: backend-degraded circuit-breaker state
src/frontend/apiserver/backend/reconnect.go # NEW: exported Go rejoin logic (re-sends
# xcecli_join for HostRole_apiserver, clears
# health.go state); called from clib.go's C
# shim AND directly from the heartbeat callback
src/frontend/apiserver/backend/heartbeat.go # MODIFIED: tick callback detects a
# degraded-to-healthy transition after
# xcecli_heartbeat succeeds and explicitly
# triggers reconnect.go's rejoin logic
src/frontend/apiserver/utils/config.go # no schema change (Peers already exists)
src/worker/testsuite/test_e2e_multimaster_join.c # NEW: candidate-list join + reconnect E2E
src/scheduler/testsuite/test_e2e_multimaster_join.c # NEW: same, scheduler
src/frontend/apiserver/backend/health_test.go # NEW: circuit-breaker unit tests
src/frontend/apiserver/backend/clib_test.go # + candidate-list parity tests (existing file)
controller/andlibxce's failover mechanism itself are not modified — only consumed.
System Flows
Startup connect (Req 1)
sequenceDiagram
participant Daemon
participant Convert as xce_cluster_config_to_client_config
participant Ctx as xcecli_ctx (client.c)
participant CandA as Candidate A (standby)
participant CandB as Candidate B (primary)
Daemon->>Convert: full candidate list (validated)
Convert-->>Daemon: xce_client_config_t (nmasters=N)
Daemon->>Ctx: xcecli_ctx_init + xcecli_start
Ctx->>CandA: connect
CandA-->>Ctx: connected, not primary
Ctx->>Daemon: xcecli_join (role)
Ctx->>CandA: join request
CandA-->>Ctx: standby redirect
Ctx->>Ctx: rotate to next candidate
Ctx->>CandB: connect + join
CandB-->>Ctx: joined as primary
Ctx-->>Daemon: xcecli_join success
Runtime failover (Req 2, Req 3)
sequenceDiagram
participant Daemon
participant Ctx as xcecli_ctx lock held
participant OldPrimary as Old Primary dies
participant NewPrimary as New Primary
Daemon->>Ctx: xcecli_* call existing traffic
Ctx->>OldPrimary: send
OldPrimary--xCtx: connection lost
Ctx->>Ctx: XCECLI_FAILOVER_CALL rotates and backs off
Ctx->>NewPrimary: reconnect
NewPrimary-->>Ctx: connected
Ctx->>Daemon: invoke rejoin callback lock still held
Note over Daemon: callback only sets a needs-rejoin flag, no I/O, returns 0
Ctx->>NewPrimary: resume original call
NewPrimary-->>Ctx: reply
Ctx-->>Daemon: original call result, lock released
Note over Daemon: after the call returns, lock is free
Daemon->>Daemon: check and clear needs-rejoin flag
Daemon->>Ctx: xcecli_join role separate call
Ctx->>NewPrimary: join
NewPrimary-->>Ctx: joined
Ctx-->>Daemon: rejoin complete
Key decision reflected in the diagram: the rejoin callback invoked by the library never performs I/O — it only flags that a rejoin is needed (see "Rejoin callback constraint" above, avoiding a self-deadlock on the still-held per-ctx lock). The actual xcecli_join call happens as a distinct follow-up call from the daemon, on the same thread (worker: connector thread; scheduler: allocator thread; apiserver: the requesting goroutine), once the triggering call has returned and released the lock — no separate reconnect loop or new thread is introduced either way.
apiserver fail-fast while degraded (Req 4)
sequenceDiagram
participant Client as API Client
participant Handler as apiserver handler
participant Health as health.go
participant Ctx as xcecli_ctx (gCtx)
Client->>Handler: HTTP request needing backend
Handler->>Health: is backend degraded?
alt degraded
Health-->>Handler: yes
Handler-->>Client: 503 service unavailable (fast)
else healthy
Health-->>Handler: no
Handler->>Ctx: backend call
alt call exhausts retry budget
Ctx-->>Handler: error
Handler->>Health: mark degraded (with TTL)
Handler-->>Client: 503 service unavailable
else call succeeds
Ctx-->>Handler: result
Handler-->>Client: 200 with result
end
end
apiserver idle-period reconnect (Req 2.4)
sequenceDiagram
participant HB as heartbeat.go tick
participant Ctx as xcecli_ctx (gCtx, FF_CALL)
participant Rejoin as reconnect.go
participant Health as health.go
participant NewPrimary as New Primary
Note over HB: no client requests in flight
HB->>Ctx: xcecli_heartbeat (fire-and-forget)
Ctx->>Ctx: connection known-dead: best-effort rotate + reconnect
Ctx->>NewPrimary: reconnected transport only
Ctx-->>HB: rc success (was failing, now succeeds)
HB->>HB: detect failure-to-success transition
HB->>Rejoin: explicitly trigger rejoin
Rejoin->>NewPrimary: xcecli_join(HostRole_apiserver)
NewPrimary-->>Rejoin: joined
Rejoin->>Health: MarkHealthy()
This path exists because XCECLI_FF_CALL (fire-and-forget, used by the heartbeat) never invokes the library's own rejoin hook — only the full-retry path (XCECLI_FAILOVER_CALL, used by write calls) does. Without this explicit trigger, an idle apiserver instance would repair its TCP connection on every heartbeat tick but never re-establish cluster membership, so push notifications would stay dead until an unrelated write request happened to arrive.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1-1.3 | Try each candidate in order at startup | libxce (unchanged), ConfigConverter | xcecli_start, xcecli_join |
Startup connect |
| 1.4 | Worker uses full list on FIRST attempt | WorkerControllerConnector | controller_connector.c init order fix |
Startup connect |
| 1.5 | Single/no-candidate mode unchanged | All three | ConfigConverter (nmasters=1 path) |
— |
| 1.6 | Clear startup failure if all candidates fail | All three | existing xcecli_start/xcecli_join return codes |
Startup connect |
| 2.1-2.3 | Automatic reconnect + retry until success or shutdown | libxce (unchanged), all three's rejoin callbacks | xcecli_set_reconnect_handler |
Runtime failover |
| 2.4 | apiserver resumes push notifications after reconnect (incl. idle-period recovery) | ApiserverBackendBridge | reconnect.go's actual-rejoin function re-sends xcecli_join(HostRole_apiserver), triggered via the shared post-call helper's flag check on write-path reconnect AND explicitly from heartbeat.go on idle-period recovery |
Runtime failover, apiserver idle-period reconnect |
| 3.1-3.2 | Retry/backoff doesn't block other liveness work | WorkerControllerConnector (thread isolation preserved), SchedulerControllerClient (allocator/picker thread separation) | existing thread structure | Runtime failover |
| 4.1-4.2 | apiserver fails fast to clients while degraded | ApiserverBackendHealth | health.go |
apiserver fail-fast flow |
| 5.1-5.2 | Per-candidate connect/reconnect logging | All three | log statements at each rotation | Startup connect, Runtime failover |
| 6.1-6.2 | Consistent candidate-list syntax | ApiserverConfigParity | CGO call to xce_parse_peers/xce_cluster_config_validate |
Startup connect |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| ConfigConverter | libxce (C) | Convert validated candidate list to client config | 1.1-1.6, 6.1 | xce_cluster_config_validate (P0) |
Service |
| WorkerControllerConnector | worker (C) | Single init/connect/join + rejoin for worker | 1.1-1.4, 2.1-2.3, 3.1-3.2, 5.1-5.2 | ConfigConverter (P0), xcecli_* (P0) |
Service |
| SchedulerControllerClient | scheduler (C) | Single init/connect/join + rejoin for scheduler | 1.1-1.3, 1.5-1.6, 2.1-2.3, 3.1-3.2, 5.1-5.2 | ConfigConverter (P0), xcecli_* (P0) |
Service |
| ApiserverBackendBridge | apiserver (Go/CGO) | Single init/connect/join for apiserver; config parity | 1.1-1.6, 2.4, 5.1-5.2, 6.1-6.2 | ConfigConverter via CGO (P0), xcecli_* via CGO (P0) |
Service |
| ApiserverBackendHealth | apiserver (Go) | Fail-fast circuit breaker for backend-down state | 4.1-4.2 | ApiserverBackendBridge (P0, consumes rejoin/error signals) | State |
Shared Library
ConfigConverter
| Field | Detail |
|---|---|
| Intent | Turn a validated xce_cluster_config_t into a fully-populated xce_client_config_t, clamped to N_MAX_MASTERS with a WARN on truncation |
| Requirements | 1.1, 1.2, 1.3, 1.5, 1.6, 6.1 |
Responsibilities & Constraints
- Pure, socket-free transformation (mirrors
xce_test's existing inline logic attest.c:360-372, generalized into one reusable function). - Does not call
xce_cluster_config_validate()itself — callers validate first (existing contract, unchanged) and pass a pre-validated config. - Does not own
from/key(identity/auth) — caller sets those on the output struct after conversion, matching howxce_test, worker, and scheduler already source them independently (CLI/env-specific).
Dependencies
- Inbound: WorkerControllerConnector, SchedulerControllerClient, ApiserverBackendBridge (all P0)
- Outbound: none (leaf function)
Contracts: Service [x]
Service Interface
/* xce/cluster.h */
/**
* xce_cluster_config_to_client_config() — convert a validated candidate list
* into an xce_client_config_t's master fields (nmasters/masters/master_ports).
*
* Copies up to N_MAX_MASTERS candidates from cfg->candidates[] in order.
* If cfg->candidates_count > N_MAX_MASTERS, copies the first N_MAX_MASTERS and
* logs a WARN identifying the truncated count (mirrors xce_test's existing
* clamp behavior).
*
* Does NOT set out->from / out->key — caller sets those separately.
* Does NOT validate cfg — caller must have already called
* xce_cluster_config_validate() successfully.
*
* Returns 0 on success, -EINVAL if cfg or out is NULL or cfg->candidates_count < 1.
*/
int xce_cluster_config_to_client_config(const xce_cluster_config_t* cfg,
xce_client_config_t* out);
- Preconditions:
cfgalready passedxce_cluster_config_validate(). - Postconditions:
out->nmastersin[1, N_MAX_MASTERS];out->masters[i]/out->master_ports[i]populated fori < out->nmasters, in the same order ascfg->candidates[]. - Invariants: never writes beyond
N_MAX_MASTERSentries regardless ofcfg->candidates_count.
Worker
WorkerControllerConnector
| Field | Detail |
|---|---|
| Intent | Single connect/join at startup using the full candidate list; transparent reconnect via the library; re-join after reconnect |
| Requirements | 1.1-1.4, 2.1-2.3, 3.1-3.2, 5.1-5.2 |
Responsibilities & Constraints
- Replaces
xcecd_join_cluster's per-candidate loop (controller_connector.c:237-311) with: buildxce_client_config_tvia ConfigConverter once (candidates fromcc_ctx->cluster_cfgwhen--peersgiven, else today's single-host fallback path unchanged per Req 1.5), then onexcecli_ctx_init+xcecli_start+xcecli_join. - Fixes the init-order bug by moving
cc_ctx->cluster_cfgpopulation (main.c) to beforexcecd_start()is called, so the very first connect already sees the full candidate list (Req 1.4). - Registers a rejoin callback (
xcecli_set_reconnect_handler) that only sets aneeds_rejoinflag and returns0— per "Rejoin callback constraint" above, it must not callxcecli_join(or anyxcecli_*function) directly, since the library invokes it whileac->failover.lockis still held. checking_connection()'s periodicxcecli_hellokeeps its existing role (liveness probe on the connector thread) but no longer needs to manually callxcecd_join_clusteron failure — a transport failure insidexcecli_helloalready triggers the library's transparent reconnect + the registered flag-setting callback. Immediately after eachxcecli_hellocall returns (lock now free),checking_connection()checks and clearsneeds_rejoin; if set, it callsxcecli_join(ctx, HostRole_worker, &host_attrs)as a separate call — the same join payload used at startup.- Runs entirely on the existing dedicated connector thread (
start_controller_connector_thread/connector_to_controller), preserving isolation fromhardware_monitor/system_monitor/process_managerupdate paths (Req 3.1) — no new thread introduced.
Dependencies
- Inbound:
xcecd_start()(P0) - Outbound: ConfigConverter (P0),
xcecli_*(P0)
Contracts: Service [x]
Implementation Notes
- Integration: preserve existing WARN-level log statements around standby-redirect and candidate-exhaustion, now emitted from the library's classification path or immediately around the single init/join call, so Req 5 diagnostics are not lost in the simplification.
- Validation: implementation must confirm (code read, not test-only) that
hardware_monitor_update/system_monitor_update/process_manager_updatedo not execute on the connector thread — flagged as a Validation Hook, not re-verified in this design pass. - Risks: deleting the hand-rolled loop must not silently drop the
MAX_RETRY_COUNT/RETRY_INTERVALbounded-retry-then-handle_disconnect()behavior inchecking_connection()— that outer bound (Req 2.3's "keep retrying" vs. any existing bounded-give-up behavior) must be reconciled explicitly during implementation, not left ambiguous.
Scheduler
SchedulerControllerClient
| Field | Detail |
|---|---|
| Intent | Single connect/join at startup using the full candidate list; transparent reconnect via the library; re-join after reconnect |
| Requirements | 1.1-1.3, 1.5-1.6, 2.1-2.3, 3.1-3.2, 5.1-5.2 |
Responsibilities & Constraints
- Replaces
sched_init's inline per-candidate loop (scheduler.c:462-495) with ConfigConverter + onexcecli_ctx_init/xcecli_start/xcecli_joincall. - Registers a rejoin callback that only sets a
needs_rejoinflag and returns0(same constraint as worker — the library invokes it whileac->failover.lockis held, so it must not callxcecli_joinitself). After eachxcecli_jobdispatchcall returns, the allocator checks and clears the flag; if set, it callsxcecli_join(ctx, HostRole_scheduler, NULL)as a separate call before continuing. - No new thread:
xcecli_jobdispatch(the only backend call site,scheduler.c:1289) continues to run from the existingsched_allocatorthread; blocking during a reconnect/backoff cycle is an accepted, benign degradation since the allocator has no useful work without a controller anyway, andsched_job_pickerruns on its own separate thread unaffected (Req 3.1-3.2 satisfied by the existing thread split, not new code).
Dependencies
- Inbound:
sched_init()(P0) - Outbound: ConfigConverter (P0),
xcecli_*(P0)
Contracts: Service [x]
Implementation Notes
- Integration: preserve the existing
WARN/INFOlog lines around candidate parsing (main.c) and add per-attempt/per-outcome logging around the single init/join call (today's loop logged each attempt; the library's own classification path should be checked for equivalent visibility, else add it locally). - Risks: today's total absence of reconnect logic means this is additive behavior change (scheduler previously required a manual restart after primary loss) — call out explicitly in the task/rollout notes so operators aren't surprised by scheduler now retrying indefinitely instead of exiting.
apiserver
ApiserverBackendBridge
| Field | Detail |
|---|---|
| Intent | Single connect/join at startup using the full candidate list via the C library (through CGO), replacing the Go-level per-peer loop; config-syntax parity with scheduler/worker |
| Requirements | 1.1-1.6, 2.4, 5.1-5.2, 6.1-6.2 |
Responsibilities & Constraints
RegisterToBackend()(clib.go) is rewritten: build a Cxce_cluster_config_tfromconfig.Peers(or the singleAddress/Portfallback, Req 1.5) by callingxce_parse_peersvia CGO (not the existing hand-written GoparsePeer), validate withxce_cluster_config_validate, convert with ConfigConverter, then callxce_backend_initonce with the resultingnmasters > 1config — replacing the currentfor _, peer := range config.Peers { tryConnect(...) }Go loop entirely.reconnect.goholds two plain Go functions: (1) a//export-annotated flag-setter — registered as the library's rejoin callback, does no I/O, only sets aneeds_rejoinatomic flag and returns0(per "Rejoin callback constraint" above: the library invokes it whileac->failover.lockis held, so it must not callxcecli_join/any CGO backend function itself); (2) a plain Go function that performs the actual rejoin — callsxcecli_join(gCtx.ctx, HostRole_apiserver, NULL)and clearsApiserverBackendHealth's degraded state on success.- The flag-setter is registered with the C library from
clib.go's own cgo preamble, via a small static C shim (xce_backend_rejoin_handler, mirroring the existingxce_backend_handler/go_push_handlerpattern already used for push dispatch) passed toxcecli_set_reconnect_handler— cgo symbol resolution is per-file (seewire_dispatch_support.go's documented rationale for the identicalgo_push_handlerwrapper), so the shim and the registration call must live in the same file as theexterndeclaration, not standalone inreconnect.go. - Rather than duplicating the same pre-call/post-call logic across all four wrapper functions, the pre-call
IsDegraded()fail-fast check (ApiserverBackendHealth, below) and the post-callneeds_rejoincheck-and-clear are both implemented once, in a single shared internal helper each of the fourclib.gowrappers calls around itsC.xcecli_*invocation — not copy-pasted per wrapper. - Idle-period reconnect gap (Req 2.4) fix:
XCECLI_FF_CALL— the macro backingxcecli_heartbeat,heartbeat.go's existing periodic prober (cmd/main.go:76) — performs a best-effort transport reconnect on a known-dead connection but never invokes the library's rejoin hook (verified againstclient_internal.h/client_failover.c:xcecli_invoke_rejoinis called only fromxcecli_retry_next, the full-retry path used by write calls, never from the fire-and-forget path). Left as-is, a controller failover occurring while apiserver has no in-flight write request would silently repair the TCP connection without ever re-joining, permanently starving push notifications until an unrelated write request happens to arrive. Fix: the heartbeat tick callback (heartbeat.go) tracks the previous tick's outcome; when a tick transitions from failure to success (transport just recovered), it explicitly callsreconnect.go's rejoin function itself, then clearsApiserverBackendHealth. This reuses the same rejoin logic Task 4.3 already builds — no duplicate join-payload code, just an explicit trigger point the write-call path already gets for free from the library.
Dependencies
- Inbound:
cmd/main.go(P0) - Outbound: ConfigConverter via CGO (P0),
xcecli_*via CGO (P0), ApiserverBackendHealth (P1, signals it on outcome)
Contracts: Service [x]
Service Interface
// backend/clib.go
// RegisterToBackend initialises the client using the full configured
// candidate list (config.Peers, or the single Address/Port fallback) and
// registers the rejoin callback. Single call — no per-peer Go loop.
func RegisterToBackend(config *BackendConfig) error
- Preconditions:
StartNotificationListeneralready called (unchanged from today). - Postconditions: on success,
gCtx.ctxis joined withnmastersreflecting every configured candidate (clamped per ConfigConverter); the rejoin callback is registered. - Invariants: candidate-list parsing/validation errors surface the same way
xce_parse_peers/xce_cluster_config_validatereport them in C (Req 6.2) — not a separately-worded Go error.
Implementation Notes
- Integration:
parsePeer(Go) and its tests are removed/retired in favor of the CGO-routed parser (Req 6.1). - Integration: the rejoin C shim must be defined in
clib.go's cgo preamble (notreconnect.go) — see Responsibilities above; this was the design's one build-risk item, now resolved by construction rather than left as an implementation-time surprise. - Risks: CGO error surfaces (C
-EINVALetc.) need a clear Go-side error message mapping so operators get an actionable message (Req 1.6, 6.2), not a bare error code.
ApiserverBackendHealth
| Field | Detail |
|---|---|
| Intent | Let request handlers fail fast (503) instead of blocking on the library's full retry budget when the backend is known-degraded |
| Requirements | 4.1, 4.2 |
Responsibilities & Constraints
- New
health.go: an atomic "degraded-until" timestamp.IsDegraded() bool— true ifnow < degraded-until.MarkDegraded(ttl time.Duration)— called by backend-call wrapper functions inclib.gowhen a call's returned error indicates retry-budget exhaustion (e.g.E_XCE_NOT_READY).MarkHealthy()— called by the rejoin callback (reconnect.go) on a successful rejoin. - Backend-call wrapper functions used by request-handling code paths (job submit, job control, queue control, host control, etc. — the existing
gCtx.ctx-based calls inclib.go) checkIsDegraded()first, via the shared internal helper described under ApiserverBackendBridge above (not duplicated per wrapper); if true, return a clear "backend unavailable" error immediately instead of invoking the blocking C call (Req 4.1). Calls that don't require the backend are unaffected (Req 4.2 — nothing to change there, they already don't touchgCtx).
Dependencies
- Inbound: ApiserverBackendBridge (sets state on outcomes)
- Outbound: none
Contracts: State [x]
State Management
- State model: single atomic degraded-until timestamp (int64 unix-nano via
sync/atomic, same pattern as existingheartbeatIntervalSecsinheartbeat.go). - Persistence & consistency: process-local, in-memory only; reset on apiserver restart (matches the rest of apiserver's in-memory state model per
tech.md). - Concurrency strategy: lock-free atomic read/write; safe for concurrent goroutines per request.
Implementation Notes
- Integration: TTL-based clearing (Risks & Mitigations in research.md) in addition to the rejoin-callback clear, so a missed/edge-case rejoin doesn't permanently wedge the flag.
- Validation: unit tests cover degraded→healthy transitions via both the TTL expiry and explicit
MarkHealthy().
Error Handling
Error Strategy
No new error taxonomy — reuse the existing controller status codes (E_XCE_REDIRECT/E_XCE_STANDBY/E_XCE_NOT_READY, errnox.h) that client_failover.c already classifies, and the existing xce_cluster_config_validate() -EINVAL cases for malformed candidate-list config.
Error Categories and Responses
- Malformed candidate-list config (Req 1.6, 6.2):
xce_parse_peers/xce_cluster_config_validatefailure at startup → each component reports a clear message naming the offending entry and refuses to start (unchanged behavior for worker/scheduler; apiserver gains the same via the CGO route). - All candidates unreachable at startup (Req 1.6):
xcecli_start/xcecli_joinreturns the last transport error after exhausting all candidates → component reports which candidates were attempted (Req 5.2) and fails startup (worker/scheduler: process exits as today; apiserver:RegisterToBackendreturns an error,cmd/main.go's existing handling applies unchanged). - Runtime disconnect (Req 2): transparently retried/rotated by the library; components only observe it through their own periodic calls' return codes and the rejoin callback — no new error category, just new logging around the existing outcomes.
- Candidate count exceeds
N_MAX_MASTERS: ConfigConverter clamps and WARNs (not an error) — matchesxce_test's existing behavior exactly. - apiserver backend degraded (Req 4.1): mapped to a clear 503-style API error, distinct from other 5xx causes, so operators/clients can distinguish "backend down, retrying" from an unrelated internal error.
Monitoring
Per-candidate connect/reconnect attempts and outcomes are logged (Req 5) using each component's existing logger (XCECD_LOG for worker, the scheduler's existing INFO/WARN macros, zap for apiserver) — no new logging infrastructure, just new call sites around the simplified connect/reconnect path.
Testing Strategy
- Unit Tests:
ConfigConverter: clamps atN_MAX_MASTERSwith WARN when given more candidates; preserves candidate order; rejectsNULL/empty input (test_cluster.c).- apiserver
health.go: degraded→healthy viaMarkHealthy(), via TTL expiry; concurrent goroutine safety (health_test.go). - apiserver CGO-routed parser: same malformed-input cases scheduler/worker already reject, now asserted through the Go call site (
clib_test.go). - Rejoin flag-setter callbacks (worker, scheduler, apiserver): with a real
ac->failover.lockheld (simulating the library's call context per "Rejoin callback constraint"), invoking the registered callback returns promptly without blocking and performs no I/O — a regression guard against reintroducing the self-deadlock this design pass found.
- Integration Tests:
- Worker/scheduler startup against a 3-candidate config where candidate[0] is a live standby — assert the component joins as expected without operator intervention (Req 1.1-1.3), extending each component's
testsuite/. - Worker/scheduler reconnect: kill the currently-joined primary mid-session, assert the component reconnects and re-joins the new primary without restart (Req 2.1-2.3), reusing the project's existing
/xce-deploy-ha+/xce-test-hapattern already used for controller HA soak testing — this is also the test that would hang (not fail cleanly) if the self-deadlock were reintroduced, so it must run with a wall-clock timeout that fails loud rather than hanging CI.
- Worker/scheduler startup against a 3-candidate config where candidate[0] is a live standby — assert the component joins as expected without operator intervention (Req 1.1-1.3), extending each component's
- E2E Tests:
- Full 3-node HA cluster with worker + scheduler + apiserver all configured with the full candidate list; kill the primary; assert all three reconnect, worker resumes job execution reporting, scheduler resumes dispatch, apiserver resumes serving requests and receiving push notifications (Req 2.4) — new
test_e2e_multimaster_join.cper C component plus an apiserver-side check in the existing HA soak harness. - apiserver idle-period reconnect: kill the primary while apiserver has no in-flight write request (only the heartbeat prober running); assert apiserver still rejoins and resumes push notifications, proving the heartbeat-triggered rejoin path (not just the write-call-triggered one) works (Req 2.4).
- apiserver fail-fast: with the backend killed and not yet reconnected, assert an API request returns quickly (bounded, sub-second) with a clear 503 rather than hanging for the full retry budget (Req 4.1).
- Full 3-node HA cluster with worker + scheduler + apiserver all configured with the full candidate list; kill the primary; assert all three reconnect, worker resumes job execution reporting, scheduler resumes dispatch, apiserver resumes serving requests and receiving push notifications (Req 2.4) — new
Security Considerations
No new trust boundary: candidate hosts are the same set of controllers already trusted today; TLS/identity handling is unchanged (see Non-Goals — CA-cert wiring is out of scope and pre-existing). No new authentication surface is introduced.