- config-upgrade-snapshot, raft-adapter-conformance: correct stale
phase metadata (both showed "tasks-generated" though every task was
already complete) and record their validate-impl GO decisions with
known-limitations notes (bug-1471/bug-1472-remainder, bug-1474
angle-b) for future spec ownership.
- component-failover-adoption: catch up design/requirements/research
docs to the Phase 6 fix (bug-1548/bug-1549, commit d479b707) that
landed after this spec was already marked closed.
- Promote 3 roadmap backlog items to specs (2026-08-10 discovery):
component-rejoin-resync (apiserver/scheduler rejoin resync),
worker-rejoin-resync (worker job-fact resync, hard prereq for
raft-apply-decoupling's facts half), scheduler-sim-diagnostics
(open-ended scheduler REPL debug/troubleshoot capability).
- roadmap.md updated to reflect all of the above.
- kiro-steering/kiro-steering-custom SKILL.md: fix stale template path
(templates/steering/ -> .kiro/settings/templates/steering/) plus
minor doc corrections in the other kiro-spec-* skills.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
68 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).
- Scheduler decision publishing (
xcecli_jobdispatch/xcecli_set_pend_reason) never silently drops a decision on retry-budget exhaustion — it keeps retrying that decision until success or shutdown, so scheduler-local and controller-observed job state cannot permanently diverge (Req 7).
Non-Goals
- Changing
xce_test(it is already the reference-correct client) — its clamp already referencesN_MAX_MASTERSsymbolically (test.c:372-378), so it auto-absorbs the constant reconciliation below with zero code change. - Completing or adopting the separate, unused
xce_connect_cluster()/xce_reconnect_*primitive incluster.c— superseded by the mechanism this design adopts; left untouched. - 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. - Persisting queued-but-unpublished scheduler decisions across a scheduler process crash/restart (Req 7 AC6) — Req 7's fix keeps a running scheduler process from giving up early on a network retry; it adds no WAL/persistence for the in-memory
decisions/jbufqueues, so a scheduler crash mid-outage still loses whatever was queued, exactly as it does today.
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.
- Scheduler's decision-publish retry-until-success-or-shutdown wrapper around
xcecli_jobdispatch/xcecli_set_pend_reason(Req 7) — closes the silent-drop gap found in the 2026-08-10 design validation. - Reconciling
N_MAX_MASTERS(client.h) to derive fromMAX_HA_CANDIDATES(config.h) (Req 1 AC7, scope expanded 2026-08-10 at explicit user direction — supersedes this design's earlier Non-Goal treating the mismatch as pre-existing/out-of-scope):config.hbecomes the single source of truth for "max configured controller candidates." Confirmed safe by source audit — no wire-protocol array assumes 3 (masters[]is never serialized; the controller's own peer list is a dynamic capnpList(CtrlPeer)), andconfig.hhas no include-cycle risk (it depends only on<stdint.h>;client.halready transitively seesMAX_HA_CANDIDATESviaxceapi.h/xce/types.h→config.h). - Eliminating the legacy single-host code path in worker, scheduler, and apiserver in favor of a single unified pipeline (Req 1 AC5, redefined 2026-08-10): today all three synthesize a
nmasters=1xce_client_config_tdirectly from legacy fields, bypassingxce_cluster_config_t/xce_parse_peers/xce_cluster_config_validate/ConfigConverter entirely (controller_connector.c:129-160'sinit_client_config(),scheduler.c:613's single-hostelsebranch,clib.go:79-101'stryConnect()direct-CGO-struct path). This spec now closes that parallel path: a single controller is thecandidates_count=1case of the exact same validated pipeline, not a separately-maintained mechanism.
Out of Boundary
xce_testand itsmasterssetting (unchanged, reference implementation).libxce's failover mechanism (client.c's connect/rotate/retry/backoff logic,client_internal.h,client_failover.c) — consumed as-is, not modified. Distinct fromclient.h'sN_MAX_MASTERSconstant, which this spec does change (above) — the mechanism and the struct-layout constant are different surfaces with different boundaries.xce_connect_cluster()/xce_reconnect_*(unused, untouched).- Controller-side membership/redirect logic.
- Worker job-state resync content on rejoin (
worker-rejoin-resync). - 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, or any future change toN_MAX_MASTERS/MAX_HA_CANDIDATESthat reintroduces divergence between them (this spec establishes them as one value — a regression that redefinesN_MAX_MASTERSindependently ofconfig.hagain should re-trigger review). - 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 that flag and, if set, issuesxcecli_join(...)as a separate, subsequent call — safe, since the lock is free by then. The flag is cleared only once thatxcecli_joincall itself succeeds, never merely because it was attempted (Req 2 AC5, gap found in the 2026-08-10 design validation's second pass):xcecli_joinis itselfXCECLI_FAILOVER_CALL-wrapped and can exhaust its own retry budget and return-E_XCE_NOT_READYjust like any other wrapped call. Clearing the flag unconditionally before or regardless of that outcome would leave the component transport-reconnected but not cluster-joined, with nothing left to retry the join until the next full disconnect/reconnect cycle happens to fire the rejoin callback again — an indefinite, silent orphaning, not a bounded delay. On failure the flag stays set, so the component's next naturally-occurring opportunity (worker: nextxcecli_helloreturn; scheduler: next decision-publish return, itself now retried indefinitely per Req 7; apiserver: next backend-call wrapper return) re-attempts the join. - 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/client.h # fix: N_MAX_MASTERS derives from
# config.h's MAX_HA_CANDIDATES (3->4)
# instead of an independent literal (Req 1.7)
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/lib/testsuite/test_libxce_retry.c # fix: fo_make_client_masters() currently
# hardcodes 3 literal host/port entries then
# copies N_MAX_MASTERS slots -- update to match
# the raised N_MAX_MASTERS, not silently copy
# an uninitialized 4th slot
src/worker/src/controller_connector.c # rewrite: delete init_client_config()'s
# standalone xce_client_config_t construction
# and xcecd_join_cluster()'s separate
# single-host-fallback branch (both bypassed
# xce_cluster_config_t entirely) -- single
# init+start+join via the one synthesize-
# validate-convert-connect sequence, whether
# the candidate list came from --peers or was
# synthesized as one candidate (Req 1.5);
# register rejoin callback
src/worker/src/main.c # fix: populate cc_ctx->cluster_cfg BEFORE
# xcecd_start() (removes init-order bug);
# fix hardcoded `if (candidates_count > 3)`
# (main.c:414) to reference N_MAX_MASTERS
# symbolically instead of a literal 3, so it
# tracks the raised value automatically
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 AND the separate
# "single-host path (original)" else branch
# (scheduler.c:613, bypassed
# xce_cluster_config_t entirely) -- single-host
# is now candidates_count=1 through the same
# sequence (Req 1.5); register rejoin callback;
# sched_publish_dispatch/sched_publish_pend_reason
# gain a retry-until-success-or-shutdown wrapper
# around their xcecli_* call (Req 7)
src/scheduler/src/main.c # fix hardcoded `if (candidates_count > 3)`
# (main.c:314) to reference N_MAX_MASTERS
# symbolically, same as worker's fix above;
# no ordering change needed (already correct)
src/frontend/apiserver/backend/clib.go # rewrite RegisterToBackend: builds exactly ONE
# xce_cluster_config_t regardless of source
# (config.Peers parsed via xce_parse_peers, or
# a synthesized single candidate from
# config.Address/config.Port when Peers is
# empty -- Req 1.5) then the same
# validate/xce_cluster_config_to_client_config/
# connect sequence either way; deletes
# tryConnect()'s direct C.xce_client_config_t
# construction and the Go-level per-peer loop
# entirely; 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
src/frontend/apiserver/backend/peers.go # DELETED: hand-written parsePeer() retired in
# favor of the CGO-routed xce_parse_peers (Req 6.1)
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 sets a needs_rejoin
# flag on a failure->success transport
# transition (FF_CALL never sets it itself),
# then on every subsequent tick where
# xcecli_heartbeat succeeds AND needs_rejoin is
# still true (not gated on
# ApiserverBackendHealth.IsDegraded(), which has
# an independent TTL -- Req 2.5) retries
# reconnect.go's rejoin function until it
# actually succeeds
src/frontend/apiserver/utils/config.go # no schema change (Peers/Address/Port already exist)
src/worker/testsuite/test_e2e_multimaster_join.c # NEW: candidate-list join + reconnect E2E;
# also covers the unified single-host path
# (no --peers) hitting the same code (Req 1.5)
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: the main driver-loop thread, inside sched_publish_dispatch immediately after xcecli_jobdispatch returns — not the allocator thread, see SchedulerControllerClient below; 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 (transport reconnected)
Note over HB: was failing, now succeeds -> set needs_rejoin=true (FF_CALL never sets it for us)
HB->>HB: needs_rejoin still true?
HB->>Rejoin: trigger rejoin
Rejoin->>NewPrimary: xcecli_join(HostRole_apiserver)
alt join succeeds
NewPrimary-->>Rejoin: joined
Rejoin->>HB: clear needs_rejoin
Rejoin->>Health: MarkHealthy()
else join's own retry budget exhausted
NewPrimary--xRejoin: -E_XCE_NOT_READY
Note over HB: needs_rejoin stays true (independent of Health's TTL-governed state) — next tick retries
end
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 controller is candidates_count=1 of the one mechanism, not a separate fallback (redefined 2026-08-10, was "unchanged single-host mode") |
All three | ConfigConverter (nmasters=1 case, no branch) — worker's init_client_config()/single-host branch, scheduler's single-host else branch, and apiserver's tryConnect() all deleted |
Startup connect |
| 1.6 | Clear startup failure if all candidates fail | All three | existing xcecli_start/xcecli_join return codes |
Startup connect |
| 1.7 | MAX_HA_CANDIDATES (config.h) is the single source of truth for max candidates; N_MAX_MASTERS derives from it |
ConfigConverter (clamp becomes defensive, not normally triggered) | client.h's N_MAX_MASTERS #define |
— |
| 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 |
| 2.5 | Re-join call itself retries on failure, not treated as one-shot | WorkerControllerConnector, SchedulerControllerClient, ApiserverBackendBridge | flag/degraded-state clears only on xcecli_join success, not on mere attempt; apiserver's idle-path checks "still degraded" every tick, not just the failure→success edge |
Runtime failover, apiserver idle-period reconnect |
| 3.1-3.3 | Retry/backoff doesn't block other liveness work | WorkerControllerConnector (thread isolation, full isolation per AC1), SchedulerControllerClient (bounded, occupancy-dependent buffering via jbuf/decisions cbufs per AC2 — narrower than worker's guarantee by design, no job lost per Req 7, resolved 2026-08-10) |
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 |
| 7.1-7.6 | Scheduler decision publish survives retry-budget exhaustion without loss | SchedulerControllerClient | sched_publish_dispatch/sched_publish_pend_reason retry-until-success-or-shutdown wrapper |
Runtime failover |
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.7, 6.1 | xce_cluster_config_validate (P0) |
Service |
| WorkerControllerConnector | worker (C) | Single init/connect/join + rejoin for worker | 1.1-1.5, 2.1-2.3, 2.5, 3.1, 3.3, 5.1-5.2 | ConfigConverter (P0), xcecli_* (P0) |
Service |
| SchedulerControllerClient | scheduler (C) | Single init/connect/join + rejoin for scheduler; decision-publish never silently drops on retry-budget exhaustion | 1.1-1.3, 1.5-1.7, 2.1-2.3, 2.5, 3.2-3.3, 5.1-5.2, 7.1-7.6 | ConfigConverter (P0), xcecli_* (P0) |
Service |
| ApiserverBackendBridge | apiserver (Go/CGO) | Single init/connect/join for apiserver; config parity | 1.1-1.7, 2.4-2.5, 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, 1.7, 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). - Handles
nmasters=1as the ordinary case, not a special one (Req 1 AC5): a one-candidatecfgproduces a one-masteroutthrough the identical copy loop as any other count — no branch distinguishes "single controller" from "multi controller" anywhere in this function. - The
N_MAX_MASTERSclamp is now a defensive safety net, not a normally-triggered path (Req 1 AC7): sinceclient.h'sN_MAX_MASTERSnow derives fromconfig.h'sMAX_HA_CANDIDATES, andxce_cluster_config_validate()already rejectscandidates_count > MAX_HA_CANDIDATESbefore a config ever reaches this function,cfg->candidates_count > N_MAX_MASTERScannot occur via the validated-input contract this function documents as its precondition. The clamp+WARN stays in place as a guard against a caller that skips validation or a future re-divergence of the two constants — it is intentionally not removed, just no longer load-bearing in the normal path.
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.5, 2.1-2.3, 2.5, 3.1, 3.3, 5.1-5.2 |
Responsibilities & Constraints
- Replaces
xcecd_join_cluster's per-candidate loop (controller_connector.c:239-312) with: build ONExce_cluster_config_t— fromcc_ctx->cluster_cfgwhen--peerswas given, or synthesized as a single-candidatexce_cluster_config_t(candidates_count=1, from the existing hardcoded"localhost":9990default,main.c:333-334) when it was not — run it throughxce_cluster_config_validate(), then ConfigConverter, then onexcecli_ctx_init+xcecli_start+xcecli_join. Deletesinit_client_config()'s standalone direct-construction ofxce_client_config_t(controller_connector.c:129-160) andxcecd_join_cluster()'s separate "single-host fallback (original path)" branch (controller_connector.c:297-311) — both were a wholly parallel mechanism that never touchedxce_cluster_config_t/xce_parse_peers/xce_cluster_config_validateat all (confirmed by source audit, 2026-08-10). Per the redefined Req 1 AC5, single-host is not a separately-maintained fallback; it is thecandidates_count=1input to the exact same synthesize-validate-convert-connect sequence multi-candidate configs use. - 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) — this fix composes naturally with the single-path unification above: since there is no longer a separate "first connect uses the single-host branch" code path to accidentally fall into, the only remaining ordering requirement is that whicheverxce_cluster_config_tgets built (real--peerslist or synthesized single-candidate) is fully assembled before the one synthesize-validate-convert-connect sequence runs, on both the first attempt and any later one. - 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()checksneeds_rejoin; if set, it callsxcecli_join(ctx, HostRole_worker, &host_attrs)as a separate call — the same join payload used at startup. The flag clears only on that join call's success (Req 2 AC5): ifxcecli_joinitself returns-E_XCE_NOT_READY(its own retry budget exhausted),needs_rejoinstays set, so the nextxcecli_hellotick's return retries the join —checking_connection()'s existing periodic cadence is what makes this retry naturally, no new timer needed.- Runs entirely on the existing dedicated connector thread (
start_controller_connector_thread/connector_to_controller), preserving isolation from the main-loopworker_process_update()andresource_sync_threadliveness 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 the connector thread is isolated from the main-loop
worker_process_update()and the dedicatedresource_sync_thread— the two recurring liveness paths — flagged as a Validation Hook, not re-verified in this design pass. (The three*_updatecalls previously cited —hardware_monitor_update/system_monitor_update/process_manager_update— are one-shot init calls atxcecd.c:476-480on the main thread, not recurring liveness duties; corrected per 2026-08-10 gap re-validation.) - 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.7, 2.1-2.3, 2.5, 3.2-3.3, 5.1-5.2, 7.1-7.6 |
Responsibilities & Constraints
- Replaces
sched_init's inline per-candidate loop (scheduler.c:564-624) with ConfigConverter + onexcecli_ctx_init/xcecli_start/xcecli_joincall. Deletessched_init's "single-host path (original)"elsebranch (scheduler.c:613, confirmed by source audit 2026-08-10 to be a wholly separate direct-construction mechanism when--peersis not given) — when no--peersis given,sched_initnow synthesizes a single-candidatexce_cluster_config_t(candidates_count=1, frominit_config()'s existing"localhost":9990default,main.c:143-152) and runs it through the identicalxce_cluster_config_validate()→ ConfigConverter → connect sequence as any real--peerslist, per the redefined Req 1 AC5: single controller iscandidates_count=1of the one mechanism, not a parallel fallback. --simmode early-returns beforesched_init()(main.c:296), so the peers-parsing block only reachessched_init()for normal-mode operation — the design's assumption that candidate-list wiring always runs beforesched_init()in the normal-mode path is confirmed correct; only the line numbers shifted.- 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). Corrected call site (supersedes an earlier, incorrect claim that this runs onsched_allocator):xcecli_jobdispatchis called fromsched_publish_dispatch(scheduler.c:1257), which is reached only via the decision-drain loop inxce_sched_driver_run_normal_mode_round(driver.c:765-786) — a loop that runs on the single main-loop thread (main.c'swhile(1),main.c:348-349), not onsched_allocator.sched_allocatoronly pushes decisions onto thedecisionscbuf (scheduler.c:336,387); it never callsxcecli_jobdispatchdirectly. The check-needs_rejoin-then-xcecli_joincall must therefore live insched_publish_dispatch, immediately after itsxcecli_jobdispatchcall returns (lock released) — same thread, same call frame — not insched_allocator. The flag clears only once thatxcecli_joincall succeeds (Req 2 AC5): on its own-E_XCE_NOT_READY,needs_rejoinstays set, so the next decision's publish-return retries the join — since Req 7's retry-until-success wrapper meanssched_publish_dispatchreturns (successfully) far more reliably now than before, this composes cleanly rather than needing its own separate retry loop. - Corrected thread/backpressure model (Critical Issue from 2026-08-10 design validation, resolved by narrowing below — supersedes the prior claim that
sched_job_pickerruns "unaffected"): the scheduler's real pipeline is three stages joined by twocbufs:sched_job_picker→jbuf(capacity 1,core.c:161— the scheduler-core determinism invariant, never resizable without re-derivingscheduler-core's Req 5 AC7) →sched_allocator→decisions(capacityndecisions=5,core.c:84) → the main-loop thread's decision-drain loop →xcecli_jobdispatch. When that call blocks insideXCECLI_FAILOVER_CALL's retry/backoff (lock held), the main thread stops drainingdecisions.sched_allocatorcan still produce more decisions before its owncbuf_put(&core.decisions, ...)blocks, andsched_job_pickercan still produce jobs intojbuffor as long as allocator keeps draining it. So picker and allocator are buffered, not isolated: they continue briefly past the point the outage begins, then both stall until connectivity (and dispatch) resumes. The exact size of that grace window is occupancy-dependent, not a fixed constant: ifdecisionshad Q items queued (1≤Q≤5) at the moment the main thread's pop grabs the decision that turns out to be un-publishable, allocator can push (6-Q) more decisions before it stalls, and picker's total new picks during the window come out to (7-Q) — ranging from 2 (Q=5,decisionswas already near-full, common under load) up to 6 (Q=1,decisionswas nearly empty). Earlier text in this section claimed "at least 6" as a guaranteed floor — that was backwards: 6 is the best-case ceiling (ndecisions+1), the guaranteed floor is as low as 1-2 depending onjbuf's own occupancy at the same instant. This is a materially different, weaker guarantee than "unaffected," and a materially different, more accurate one than the original "at least 6" claim. This "stall until resumes" description is only actually true once the Req 7 fix below is in place — see that bullet for why, unfixed, the main thread can instead silently unstick itself by dropping a decision rather than genuinely staying stalled. - Decision-publish retry-until-success-or-shutdown wrapper (Req 7, gap found in 2026-08-10 design validation):
xcecli_jobdispatch/xcecli_set_pend_reasonareXCECLI_FAILOVER_CALL-wrapped, which retries a single call up to a bounded budget (max_retries=20, backoff capped atmax_retry_ms=5000ms — roughly 1-2 minutes total,client_failover.c:281-282,312-317) before returning-E_XCE_NOT_READY. Today's driver decision-drain loop (driver.c:783-789) does not inspect this return code — it logs and advances to the next queued decision regardless of outcome. Sincesched_commit_decisionalready mutates scheduler-local job state (job->allocs, accounts) before the publish attempt (scheduler.csched_allocator), a budget-exhausted publish today silently and permanently diverges scheduler-local state from what the controller was told — not a delay, a loss. Fix:sched_publish_dispatch/sched_publish_pend_reason(scheduler.c:1257/1251) wrap theirxcecli_*call inwhile (!sched->core.stop) { rc = XCECLI_FAILOVER_CALL(...); if (rc != -E_XCE_NOT_READY) break; WARN(extended-retry, ...); }— checkingcore.stopbefore each attempt, not only after (a plaindo/whilechecking the stop condition only at the bottom would let every already-queued decision behind the one currently stuck each burn one full ~100s budget cycle discovering shutdown was requested, compounding to minutes-per-queued-decision instead of a single bounded delay — caught in the 2026-08-10 design validation's third pass). Re-invoking the whole macro on each loop iteration resets its own internal retry state and starts a fresh budget, rather than adding a second retry mechanism. This is a straightforward extension of Req 2 AC3's existing "keep retrying, never permanently give up" language, applied per-decision instead of only at the connection level. Shutdown interaction:stop_scheduler()'s existingcbuf_tryput(&core.decisions, NULL)wake-up (main.c:163) only unblocks a thread parked incbuf_get(&core.decisions)— it does nothing for a thread already inside this new retry loop, so the loop's owncore.stopcheck is the only shutdown path. With the check moved to the top, only the ONE decision actively in-flight when shutdown is requested can cost up to one full budget cycle (~1-2 minutes); every decision still queued behind it indecisionsseescore.stopalready true and returns immediately without attempting a call, so total shutdown latency stays bounded to a single cycle regardless of queue depth — an accepted bounded tradeoff (same posture as scheduler-core's accepted 5sOpCode_schedAdminblocking-wait case) sincelibxce's retry macro itself is out of this spec's boundary to modify for a faster mid-attempt shutdown check. Ordering: decisions remain published strictly indecisionscbuf FIFO order (Req 7 AC3) because the drain loop still only advances tocbuf_get()the next decision after the current one's publish call returns — a stuck decision blocks the whole loop, it does not get skipped and revisited later. Rejoin-check interaction: the existing check-needs_rejoinstep (Responsibilities bullet above) sits after this wholewhileloop, not after each inner attempt — it observes the loop's final, successful return, exactly the same as it would for a single non-retried call.
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). - Req 3 AC2 scope note (Critical Issue from 2026-08-10 design validation, resolved 2026-08-10): the original merged AC1 wording ("shall continue performing ... allocation/decision processing ... without being blocked") was not met unconditionally by the scheduler — only for a bounded, occupancy-dependent grace window (floor as low as 1-2 jobs, ceiling
ndecisions+1=6, see the corrected backpressure-model bullet above), after whichsched_allocatorand thensched_job_pickerdo block.jbuf's capacity is a fixed invariant owned by thescheduler-corespec (out of this spec's boundary to change) anddecisions' capacity is an existing config constant; redesigning either is out of scope here. Resolution: Requirement 3's single merged AC1 was split into a worker-only AC1 (unconditional, unchanged guarantee) and a scheduler-only AC2 (bounded — "continues briefly ... then blocks until connectivity is restored, at which point processing resumes automatically ... no job picked or decided during that bounded window is ever lost"), with the former heartbeat AC renumbered to AC3.requirements.mddeliberately does not hardcode a specific job count — see the corrected backpressure-model bullet above for why that count is occupancy-dependent (floor 1-2, ceilingndecisions+1=6), not a fixed constant an initial draft of this note incorrectly stated as a guaranteed "at least 6."requirements.mdreflects this split and the corrected framing; the design and its bounded-buffering behavior now match the requirement it's held to, with Requirement 7 supplying the actual load-bearing guarantee (no loss, regardless of how many jobs the buffer absorbs). - 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.7, 2.4-2.5, 5.1-5.2, 6.1-6.2 |
Responsibilities & Constraints
RegisterToBackend()(clib.go) is rewritten to build exactly ONE Cxce_cluster_config_tregardless of source, then run it through the single synthesize-validate-convert-connect sequence worker/scheduler use: ifconfig.Peersis non-empty, format it into the same[name=]host:port[,...]syntax and parse viaxce_parse_peers(CGO, not the existing hand-written GoparsePeer); if empty, synthesize a one-candidatexce_cluster_config_t(candidates_count=1) directly fromconfig.Address/config.Port— either way the result is validated withxce_cluster_config_validate, converted with ConfigConverter, thenxce_backend_initis called once with the resulting config (nmastersin[1, N_MAX_MASTERS], not necessarily>1). DeletestryConnect()'s directC.xce_client_config_tconstruction (clib.go:79-101) and thefor _, peer := range config.Peers { tryConnect(...) }Go-level loop (clib.go:116-135) entirely, along withparsePeer()(backend/peers.go:11, retired per Req 6.1) — bothtryConnect()'s Peers-loop call site and its no-PeersAddress/Portcall site (clib.go:138) were separate mechanisms that never touchedxce_cluster_config_tat all (confirmed by source audit, 2026-08-10); per the redefined Req 1 AC5, a bareAddress/Portconfig iscandidates_count=1of the same mechanism, not a separate fallback.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 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. The flag clears only once the follow-upxcecli_joincall succeeds (Req 2 AC5): if that call itself returns-E_XCE_NOT_READY,needs_rejoinstays set so the next backend-call wrapper's return retries the join, andMarkHealthy()is correspondingly only called on that eventual success — not on the mere attempt. - 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: on a failure→success transport transition,heartbeat.go's tick callback sets the sameneeds_rejoinatomic flag the write-path's C-side callback sets — sinceXCECLI_FF_CALLnever invokes that callback for this path,heartbeat.gosets the Go-side flag itself. On every subsequent tick wherexcecli_heartbeatsucceeds andneeds_rejoinis still true (checked every tick, not only on the transition edge — Req 2 AC5 gap found in the 2026-08-10 design validation's second pass), it callsreconnect.go's rejoin function; the flag (andApiserverBackendHealth's degraded state) clears only when that rejoin call itself succeeds. Deliberately not gated onApiserverBackendHealth.IsDegraded()(a design-validation third-pass correction over an earlier draft of this fix): that state has its own independent TTL-based auto-clear (see ApiserverBackendHealth below) for request-path circuit-breaker purposes — if the TTL expired before the rejoin actually succeeded, gating the retry on "still degraded" would stop retrying the join even though it never succeeded, silently reintroducing the same starvation this fix exists to close.needs_rejoinandApiserverBackendHealth's degraded-until timestamp are two independent pieces of state serving two different purposes (one tracks "have I re-joined yet," the other "should request handlers fail fast right now") and must not be conflated. A transition-edge-only trigger would fire the rejoin attempt exactly once per outage and, if that one attempt's ownxcecli_joinhit its retry budget and failed, never retry — every subsequent tick would see success→success (transport already up) and never re-fire; checking the persistent flag instead of the transient edge makes each tick a natural retry opportunity for as long as apiserver remains un-joined. This reuses the same rejoin logic Task 4.3 already builds — no duplicate join-payload code.
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 if set, otherwise a single-candidate list
// synthesized from config.Address/config.Port (not a separate code path,
// see Req 1 AC5) — 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(). - Deliberately independent of
needs_rejoin(Req 2 AC5, correction from the 2026-08-10 design validation's third pass):IsDegraded()'s TTL-based auto-clear exists so request handlers eventually probe again even without an explicitMarkHealthy()— a legitimate circuit-breaker half-open pattern. It must not be used as the retry-trigger condition for the heartbeat-idle-path's rejoin attempts (ApiserverBackendBridge above) — the TTL could expire before an actual rejoin succeeds, and gating the rejoin retry on "still degraded" would then silently stop retrying the join while apiserver is still not actually joined. The rejoin-trackingneeds_rejoinflag has no TTL and clears only on an actualxcecli_joinsuccess, independent of whateverApiserverBackendHealth's degraded-until timestamp is doing.
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.
- Scheduler decision-publish retry-budget exhaustion (Req 7):
-E_XCE_NOT_READYfromxcecli_jobdispatch/xcecli_set_pend_reasonis not treated as terminal — the caller re-invokes the same publish call rather than surfacing an error or dropping the decision (see SchedulerControllerClient's retry-until-success-or-shutdown wrapper). This is scheduler-specific: worker's periodicxcecli_hellocarries no payload to lose on the same error, and apiserver's request/response calls correctly surface-E_XCE_NOT_READYtoApiserverBackendHealthsince there the caller (the original HTTP client) is expected to retry, unlike scheduler's decisions which have no external retry source. - 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); acandidates_count=1input producesnmasters=1through the identical code path as any other count (Req 1.5) — no branch, no special case.N_MAX_MASTERS == MAX_HA_CANDIDATES(Req 1.7): a_Static_assert(or equivalent compile-time check) inclient.hor atest_cluster.ccase asserting the two constants stay equal — a regression guard against reintroducing the divergence this design pass reconciled. Also re-runtest_libxce_retry.c'sfo_make_client_masters()fixture with its corrected slot count and confirm it doesn't silently exercise an uninitializedmasters[3].- 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. - Rejoin retry-on-failure (Req 2 AC5, worker/scheduler/apiserver): using a test double for
xcecli_jointhat fails N times before succeeding, assert the pending-rejoin indicator (flag or degraded-state) stays set/degraded after each failed attempt and is cleared only on the eventual success; assert the next naturally-occurring opportunity (nextxcecli_hello/decision-publish/backend-call return, or next heartbeat tick for apiserver's idle path) is what re-attempts it, not a dedicated new retry loop — a regression guard against the transition-edge/clear-before-success versions of this bug found in the 2026-08-10 design validation's second pass.
- Integration Tests:
- Scheduler decision-publish redelivery (Req 7): using a test double for
xcecli_jobdispatch/xcecli_set_pend_reasonthat returns-E_XCE_NOT_READYfor N calls before succeeding, assert the same decision is retried (not dropped, not skipped) and eventually published; assert decisions queued behind it publish afterward in original order (Req 7 AC1-3); assert settingsched->core.stopmid-retry breaks the loop within one budget cycle (Req 7 AC2); assert a WARN-level log line distinguishes extended-retry success from first-attempt success (Req 7 AC4); with multiple decisions queued (decisionsat or near its 5-item capacity) whencore.stopis set mid-retry on the first, assert total shutdown latency stays bounded to roughly one budget cycle, not one per queued decision — a regression guard against the compounding-shutdown-latency bug found in the 2026-08-10 design validation's third pass. - Unified single-host path (Req 1.5): starting each of worker, scheduler, and apiserver with no
--peers/emptybackend.peersreaches the identicalxce_cluster_config_validate→ ConfigConverter → connect call sequence as a single-entry--peersconfig — assert via the same log-line signature both paths now share (there is no longer a distinct "single-host mode" log message), not just "it connects." - 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.
- Scheduler decision-publish redelivery (Req 7): using a test double for
- 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.