Requirements and design approved (design fixed the fork()-vs-posix_spawn mutex-inheritance-deadlock risk product.md's own "no fork() for snapshots" decision already rejected); tasks generated but never approved. Superseded by xce-node-manager after the user decided the controller should not directly own spawning/killing its own scheduler process -- a new per-node xce-node-manager component takes over that responsibility instead. Kept in place as a reference (notably the fork()-vs-posix_spawn analysis); this spec's tasks.md must not be implemented (see spec.json's superseded_note). Checkpointing uncommitted spec work found in this shared worktree. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAWMhfRJFnmmnG8h97yUDd
36 KiB
Overview
Purpose: This feature binds the scheduler process's lifecycle to the controller cluster's primary role, replacing the scheduler's independent-process-with-reactive-rejoin model with a controller-owned child process, so that a controller failover always results in a working scheduler within one promotion cycle instead of a potentially unbounded stuck-PEND deadlock (bug-1760).
Users: XCE cluster operators, and transitively every job submitted to the cluster, benefit from this feature never having to notice or intervene after a controller failover.
Impact: Changes the current independent, long-lived xce_scheduler process (joining any of a configured candidate list, reconnecting reactively) into a process spawned, owned, and torn down by whichever controller is currently primary, pinned to that one controller for its entire lifetime.
Goals
- A controller starts exactly one scheduler process the moment it becomes primary, and that scheduler is usable (joined, receiving state) without any operator action.
- A controller's own scheduler exits promptly on a graceful demotion, and independently detects and exits on any other loss of that controller's primacy (crash, network partition, wedge), without ever attempting to reconnect elsewhere.
- Scheduler-lifecycle management can never compromise the controller's own liveness, HA correctness, or availability.
- A wedged-but-connected scheduler is distinguished from a healthy one and recovered automatically.
- Existing tooling and specs that assume an independently-started, reconnecting scheduler are identified and updated.
Non-Goals
- No standby/redundant scheduler processes or lease-election mechanism (superseded by piggybacking on the controller's existing Raft election).
- No general per-host role-manager component governing worker/controller/scheduler/apiserver placement.
- No change to worker or apiserver deployment/supervision.
- No change to the scheduler's job/queue/config-handling logic, wire behavior toward jobs, or picker/allocator internals — only its process lifecycle and its relationship to the controller cluster.
Boundary Commitments
This Spec Owns
- The controller-side decision of when to start, stop, or restart its own scheduler child process (tied to promotion, demotion, and a detected wedge).
- The mechanism used to start/stop/monitor that child process without blocking the controller's Raft drive threads.
- The scheduler's own startup contract change (pinned to one controller, no candidate list) and its active liveness/primacy check of that one controller.
- The new controller-to-scheduler wire message used for a graceful-exit notice.
- Updating existing deployment tooling (
xce-deploy-ha,xce-test-ha) and flagging any other spec that assumes an independently-started, reconnecting scheduler.
Out of Boundary
- The scheduler's job/queue/config decision logic (picker, allocator, plugins) — untouched.
- The Raft election, replication, and commit-safety mechanisms the controller already implements — this feature is a consumer of "who is primary," never a modifier of how that is decided.
- Worker and apiserver process lifecycle.
- Any lease/renewal protocol or standby-scheduler redundancy (deliberately rejected — see research.md).
ha_dispatch_gate's existing role-based write rejection — this feature depends on it, does not change it.
Allowed Dependencies
src/controller/slowlane.c(slowlane_submit) — the sole mechanism for running scheduler-management work off the Raft drive threads. No new async-execution primitive may be introduced.src/controller/ha.c'sraft_cb_become_leader/raft_cb_become_follower— the sole trigger points for scheduler start/stop. No parallel promotion/demotion detection path may be built.src/controller/sched.c's existingon_scheduler_join()/handle_scheduler_leave()/cluster_t.sched.epbookkeeping — extended, not replaced, for tracking whether the owned child has actually joined.- The existing controller-scheduler wire connection and Cap'n Proto schema (
src/schema/controller.capnp) — the graceful-exit notice is a small addition to this existing channel, not a new transport.
Revalidation Triggers
- Any change to
raft_cb_become_leader's orraft_cb_become_follower's promotion/demotion gating order (a new gate inserted before or after the scheduler-start/stop call site changes when a scheduler is (not) started). - Any change to
slowlane_submit's dedup-key semantics or admission-cap behavior. - Any change to
ha_dispatch_gate's role-based opcode allow-lists (the safety argument that a stale scheduler connection cannot mutate state depends on this staying true). - Any other spec or tool that starts
xce_schedulerdirectly (bypassing controller-owned startup) must be re-checked against this design's single-controller startup contract.
Project Invariants Checked
This design touches the controller's HA/process-lifecycle surface, so it is checked against .kiro/steering/ha-async-io.md's documented invariants directly, not deferred to a later audit:
- Thread-role contract (no blocking work on iomgr/delaymgr): satisfied — every scheduler-management action (posix_spawn, waitpid, signal, status check) is submitted through the existing
slowlane_submit()executor, never invoked inline from a Raft callback. See Architecture Pattern & Boundary Map and the SchedulerProcessManager component. - Controller-never-self-terminate: satisfied — a scheduler-management failure (start failure, stop timeout, wedge) is logged and retried/handled locally; it never crashes, hangs, or exits the controller process, and is never wired as a new promotion-refusal gate (Requirement 4.2, 4.4). See Error Handling.
ha_dispatch_gaterole-based write rejection: depended upon, not modified — this design's safety argument (a stale/orphaned scheduler connection cannot mutate cluster state) rests on this existing, unchanged behavior (see research.md).
Architecture
Existing Architecture Analysis
- The controller today has no concept of owning a child process of any kind —
cluster_thas no process-management state. The only precedent for one XCE daemon supervising a child process is the worker daemon's job-execution supervision (process_worker_manager.c,uv_ipc.c), which is libuv-based and job-execution-specific (see research.md) and is not reused directly. - The scheduler today is a fully general Raft-cluster client: it holds a candidate list (
--peers), connects viaxcecli_start/xcecli_join, and recovers from any disconnect via the client library's ownXCECLI_FAILOVER_CALLrotation plus its own reactivesched_publish_check_rejoin(). This general-client shape is replaced for the scheduler specifically; workers and thexce_test/CLI clients are unaffected and keep the existing candidate-list/failover-rotation model. ha_dispatch_gatealready enforces that only a primary controller accepts scheduler write opcodes (job dispatch, pend-reason) — this pre-existing behavior is why this feature is purely about availability, not correctness (see research.md).
Architecture Pattern & Boundary Map
graph TB
subgraph HostA
CtrlA[Controller Instance A Primary]
SchedA[Scheduler Process]
CtrlA -->|spawns and owns| SchedA
end
subgraph HostB
CtrlB[Controller Instance B Standby]
end
subgraph HostC
CtrlC[Controller Instance C Standby]
end
CtrlA <-->|raft election and replication| CtrlB
CtrlA <-->|raft election and replication| CtrlC
CtrlB <-->|raft election and replication| CtrlC
SchedA -->|pinned single connection join and status query| CtrlA
Architecture Integration:
- Selected pattern: controller-owned child process, triggered directly from the existing Raft promotion/demotion callbacks, executed through the existing SlowLane off-drive-thread executor.
- Domain/feature boundaries: controller-side process ownership (
scheduler_proc.c, new) is separate from controller-side scheduler-connection bookkeeping (sched.c, existing, extended); scheduler-side controller-liveness watching (sched_owner.c, new) is separate from scheduler-side scheduling logic (scheduler.c, unchanged except for removing the superseded rejoin logic). - Existing patterns preserved: SlowLane for off-drive-thread work;
ha_dispatch_gateas the sole write-safety mechanism; the existing Cap'n Proto wire connection and request-handler dispatch shape on both sides. - New components rationale:
scheduler_proc.cis a new domain (process ownership) with no existing owner;sched_owner.cis a new domain (single-controller liveness pinning) that cannot be folded intoscheduler.c's existing scheduling-engine responsibility without violating single-responsibility. - Steering compliance: respects
ha-async-io.md's thread-role contract (all blocking work — posix_spawn/waitpid/kill/status-query — runs via SlowLane or the scheduler's own non-drive-thread context, never inline on iomgr/delaymgr) and the controller-never-self-terminate invariant (a scheduler-management failure logs loudly and retries; it never causes the controller to crash, hang, or exit, and never becomes a new promotion-refusal gate).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Controller process management | POSIX posix_spawn/waitpid/signal (C, src/controller/scheduler_proc.c) |
Start, monitor, and stop the scheduler child process | product.md's locked decision "no fork() for snapshots (mutex-inheritance deadlock in multithreaded controller)" applies just as much here: a raw fork() only duplicates the calling thread, so a lock held by any other thread (glibc malloc, XCE's own mylock_t locks) is duplicated in whatever state it was in, and the child can deadlock if it touches malloc or any XCE library call before execve() — being on a dedicated SlowLane worker does not change this, since the hazard comes from other threads in the same process, not the calling one. posix_spawn() is used instead specifically to avoid re-triggering this exact, already-documented risk class, not merely as a style preference |
| Async execution | slowlane_submit() (existing, src/controller/slowlane.c) |
Runs all scheduler-management actions off the Raft drive threads | Reused as-is, no changes |
| Wire protocol | Cap'n Proto (existing, src/schema/controller.capnp) |
Carries the new graceful-exit notice from controller to scheduler over the existing connection | One small schema addition; no new transport |
| Scheduler startup config | CLI argument (existing mechanism, new single-address mode) | Controller passes its own address to the scheduler it spawns, replacing the multi-candidate --peers list for this specific relationship |
Deployment tooling generating scheduler startup commands must change accordingly |
File Structure Plan
Directory Structure
src/
├── controller/
│ ├── scheduler_proc.c # NEW: owns posix_spawn/monitor/signal/reap of this controller's scheduler child
│ ├── include/
│ │ └── scheduler_proc.h # NEW: SchedulerProcessManager service interface
│ ├── ha.c # MODIFIED: call scheduler_proc_start() at the tail of raft_cb_become_leader,
│ │ # scheduler_proc_stop() at the top of raft_cb_become_follower
│ └── sched.c # MODIFIED: extend join/leave bookkeeping to feed liveness/wedge detection
├── scheduler/
│ └── src/
│ ├── sched_owner.c # NEW: pins to one controller address, periodic liveness/role check, exits on loss
│ ├── scheduler.c # MODIFIED: remove sched_needs_rejoin/sched_on_reconnect/sched_publish_check_rejoin
│ │ # and the multi-candidate startup path for this role
│ ├── handler.c # MODIFIED: scheduler_req_handler gains a case for the graceful-exit notice
│ └── main.c # MODIFIED: new single-controller-address startup argument
├── schema/
│ └── controller.capnp # MODIFIED: add the controller-to-scheduler graceful-exit message
└── controller/testsuite/, scheduler/testsuite/ # new/modified test files, see Testing Strategy
Modified Files (tooling and specs, outside src/)
.claude/skills/xce-deploy-ha/SKILL.md— remove/replace Step 4b's manualstart-scheduler.shguidance; document that a running controller now starts its own scheduler automatically once it becomes primary.src/tools/deploy-ha.sh— stop generating a standalonestart-scheduler.shintended for manual/independent invocation (or repurpose it to reflect the new single-controller-address contract if a manual escape hatch is still useful for local debugging)..claude/skills/xce-test-ha/run-ha-soak.sh— remove the worker-rejoin-resync soak coverage'sstart_worker_and_scheduler()/restart_scheduler_for_new_primary()manual scheduler management added this session (bug-1760's workaround becomes unnecessary — the controller now manages this itself); the soak harness should instead just verify a scheduler is present after each promotion.
System Flows
Promotion: controller starts its scheduler
sequenceDiagram
participant Raft as Raft Election
participant HA as ha.c become leader
participant Lane as SlowLane Executor
participant Proc as scheduler_proc.c
participant Sched as Scheduler Process
Raft->>HA: on_become_leader
HA->>HA: run existing promotion eligibility gates
HA->>Lane: slowlane_submit start action dedup key this controller
Lane->>Proc: run start action on a lane worker
Proc->>Sched: posix_spawn passing this controller address
Sched->>HA: join using pinned single controller address
HA->>Sched: push jobs queues config existing behavior
Gating conditions: the SlowLane submission only happens after every existing promotion-refusal gate has already passed (Requirement 1.1). A failed or deferred slowlane_submit() (already-in-flight dedup, or admission-cap deferral) is not an error — the work is still queued and will run (Requirement 1.5's retry is satisfied by SlowLane's own queuing semantics plus an explicit retry-on-failure inside the submitted action).
Graceful demotion: controller signals its scheduler to exit
sequenceDiagram
participant Raft as Raft Election
participant HA as ha.c become follower
participant Lane as SlowLane Executor
participant Proc as scheduler_proc.c
participant Sched as Scheduler Process
HA->>Lane: slowlane_submit stop action dedup key this controller
HA->>HA: continue completing its own role transition immediately
Lane->>Proc: run stop action on a lane worker
Proc->>Sched: send graceful exit notice over existing connection
Sched->>Sched: finish in flight work and exit
Proc->>Proc: wait bounded time then reap or force terminate
The role transition in raft_cb_become_follower never waits on the SlowLane task's completion (Requirement 2.3) — submission is fire-and-forget from the Raft callback's perspective.
Scheduler self-detects loss of its owning controller
sequenceDiagram
participant Sched as Scheduler Process
participant Owner as sched_owner.c
participant Ctrl as Its Owning Controller
loop periodic check
Owner->>Ctrl: status query over the pinned connection
Ctrl-->>Owner: role and reachability
end
alt controller unreachable or reports it is no longer primary
Owner->>Sched: request exit
Sched->>Sched: exit
end
This path is independent of the graceful-exit notice above — it is the general-case detector covering crash, network partition, and any demotion trigger the explicit notice did not reach in time (Requirement 3.1-3.3). No candidate list exists to fall back to (Requirement 3.4) — the only outcome of a failed check is exit.
Wedged-scheduler detection and recovery
sequenceDiagram
participant HA as ha.c or sched.c
participant Lane as SlowLane Executor
participant Proc as scheduler_proc.c
participant Sched as Scheduler Process
HA->>Proc: observe scheduler connected but no scheduling progress signal within threshold
Proc->>Lane: slowlane_submit restart action dedup key this controller
Lane->>Proc: run restart action
Proc->>Sched: terminate
Proc->>Proc: posix_spawn a replacement per the promotion flow
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1, 1.2, 1.3 | Scheduler starts on promotion, at most one in flight, no duplicate start | scheduler_proc.c, ha.c | scheduler_proc_start |
Promotion flow |
| 1.4 | Scheduler joins and receives job/queue/config state after being started | scheduler_proc.c, sched.c | scheduler_proc_start |
Promotion flow |
| 1.5 | Controller retries on start failure, logs loudly | scheduler_proc.c | scheduler_proc_start |
Promotion flow |
| 2.1, 2.2, 2.3 | Graceful exit signaled on demotion; scheduler exits within bounded time; controller's own role transition never waits on it | scheduler_proc.c, ha.c, controller.capnp addition, scheduler handler.c | scheduler_proc_stop, graceful-exit wire message |
Graceful demotion flow |
| 2.4 | No job left inconsistent or lost regardless of how the scheduler was terminated | scheduler_proc.c (bounded stop/force-kill), controller's existing WAL-commit ownership (unchanged) | scheduler_proc_stop |
Graceful demotion flow, Data Models |
| 3.1, 3.2, 3.3 | Scheduler actively verifies its owning controller is reachable and primary; exits on failure of either check | sched_owner.c | sched_owner_watch_start |
Self-detection flow |
| 3.4 | Scheduler never joins or operates against any controller other than the one that started it | sched_owner.c, scheduler.c (rejoin logic removed) | sched_owner_watch_start |
Self-detection flow |
| 3.5 | Abrupt host/process death needs no scheduler-side action | (no component — a structural consequence of co-location, not new logic) | n/a | Self-detection flow (contrast case) |
| 4.1, 4.2, 4.3 | Controller never blocks heartbeats/elections, never crashes/hangs, always logs scheduler-management failures | scheduler_proc.c (via SlowLane) | scheduler_proc_start/stop/check_liveness (all SlowLane-submitted) |
All flows |
| 4.4 | Absence of a running scheduler never blocks a controller's own HA role | scheduler_proc.c, ha.c (unchanged promotion gates) | n/a | Promotion flow |
| 5.1, 5.2 | Controller monitors scheduling progress, not just connection state; flags no-progress past a threshold as unresponsive | SchedulerConnectionBookkeeping (sched.c), scheduler_proc.c | scheduler_proc_check_liveness |
Wedge detection flow |
| 5.3, 5.4 | Unresponsive scheduler is terminated and replaced, with an operator-visible log | scheduler_proc.c | scheduler_proc_check_liveness |
Wedge detection flow |
| 6.1 | Existing deployment tooling documentation updated to reflect controller-owned scheduler startup | xce-deploy-ha SKILL.md, deploy-ha.sh | n/a (tooling) | Migration Strategy |
| 6.2 | Existing specs/tooling depending on scheduler survive-and-reconnect behavior identified and reconciled | xce-test-ha run-ha-soak.sh (this session's own bug-1760 workaround is the concrete example identified and reconciled here) | n/a (tooling) | Migration Strategy, Testing Strategy E2E |
| 6.3 | Scheduler's observable job/queue/config behavior unchanged while running under a live primary | scheduler.c (unchanged scheduling logic) | n/a | Data Models |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| SchedulerProcessManager | Controller | Owns posix_spawn/monitor/signal/reap of this controller's scheduler child | 1, 2, 4, 5 | SlowLane executor (P0), ha.c callbacks (P0) | Service |
| SchedulerConnectionBookkeeping | Controller | Existing join/leave tracking, extended to feed liveness/progress signal | 5 | SchedulerProcessManager (P1) | Service |
| SchedOwnerWatch | Scheduler | Pins to one controller address; periodic liveness/role check; exits on loss or graceful notice | 3 | Existing wire connection/status query (P0) | Service |
| Graceful-Exit Wire Message | Wire protocol | Controller-to-scheduler notice carried on the existing connection | 2 | Cap'n Proto schema (P0) | Event |
Controller
SchedulerProcessManager
| Field | Detail |
|---|---|
| Intent | Start, monitor, signal, and reap this controller's own scheduler child process, entirely off the Raft drive threads |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.3, 5.4 |
Responsibilities & Constraints
- Owns the decision of whether a scheduler child is currently running for this controller instance;
ha.cnever spawns or signals a process directly. - Every action (start, stop, restart) is submitted to the SlowLane executor with a dedup key scoped to this controller instance, never invoked inline from a Raft callback.
- Never allowed to block, crash, or cause the controller process to exit — every failure path logs an operator-visible error and either retries (start) or proceeds with the role transition regardless (stop).
Dependencies
- Outbound: SlowLane executor (
slowlane_submit) — P0, the only allowed execution path for this component's blocking work. - Inbound:
ha.c'sraft_cb_become_leader(callsscheduler_proc_start),raft_cb_become_follower(callsscheduler_proc_stop) — P0. - Inbound: a periodic liveness check (driven from the existing controller heartbeat cadence, not a new timer thread) — P1, feeds
scheduler_proc_check_liveness.
Contracts: Service [x]
Service Interface
/* src/controller/include/scheduler_proc.h */
/* Requests that this controller's scheduler child be started, if one is not
* already running or already starting. Non-blocking: submits to the SlowLane
* executor and returns immediately. Safe to call multiple times in quick
* succession — the SlowLane dedup key ensures at most one start action is
* ever in flight per controller instance.
* Returns 0 if the request was accepted (started or already in flight/running),
* a negative errno if the request itself could not even be submitted (e.g.
* SlowLane shutting down) — never blocks, never crashes the caller.
*/
int scheduler_proc_start(cluster_t *c);
/* Requests that this controller's scheduler child be stopped: sends the
* graceful-exit notice, waits up to timeout_ms for the child to exit, then
* force-terminates if it has not. Non-blocking to the caller: submitted to
* the SlowLane executor. Does not block the caller's own role transition.
*/
int scheduler_proc_stop(cluster_t *c, uint32_t timeout_ms);
/* Evaluates whether the currently-running scheduler child (if any) is making
* scheduling progress, using the existing push/acknowledgment traffic
* (SchedulerConnectionBookkeeping) as the progress signal. If the child is
* connected but has made no progress for longer than the configured
* threshold, submits a restart action. A no-op if no scheduler child is
* currently owned. Non-blocking.
*/
int scheduler_proc_check_liveness(cluster_t *c);
- Preconditions:
cis a valid, initializedcluster_t; called only from the controller's own process. - Postconditions: the requested action is either already complete, in flight on a SlowLane worker, or queued — never silently dropped.
- Invariants: at most one scheduler child process is owned by a given controller instance at any time; a stop-then-start (demotion followed by a fresh promotion of the same instance) always completes the stop's reap before a new start is submitted, since a demoted instance must win a fresh election before it can be promoted again.
Implementation Notes
- Integration: hook call sites are the tail of
raft_cb_become_leader(after all existing promotion-refusal gates) and the top ofraft_cb_become_follower. - Validation: an operator-visible log line on every start failure, stop-timeout-forced-kill, and liveness-triggered restart (Requirement 4.3, 5.4).
- Risks: sizing the shared SlowLane instance's
max_inflight/n_workersfor the additional scheduler-management workload alongside its existing consumers is a follow-up check, not a design change (see research.md).
SchedulerConnectionBookkeeping (extends existing sched.c)
| Field | Detail |
|---|---|
| Intent | Track whether the owned scheduler child has joined and is making scheduling progress, feeding scheduler_proc_check_liveness |
| Requirements | 5.1, 5.2 |
Responsibilities & Constraints
- Extends the existing
on_scheduler_join()/handle_scheduler_leave()/cluster_t.sched.epbookkeeping with a last-progress timestamp, updated whenever a push to the scheduler is acknowledged or a dispatch/pend-reason decision is received from it. - Does not itself decide to restart anything — it only exposes the progress signal
scheduler_proc_check_livenessreads.
Dependencies
- Inbound: existing scheduler request handling in
cluster.c/sched.c— P1. - Outbound: read by SchedulerProcessManager — P1.
Contracts: Service [x]
Implementation Notes
- Integration: reuses existing traffic; no new synthetic heartbeat payload is introduced (see research.md Risks).
Scheduler
SchedOwnerWatch
| Field | Detail |
|---|---|
| Intent | Pin the scheduler to the single controller instance that started it; periodically confirm that controller is reachable and still primary; exit otherwise |
| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5 |
Responsibilities & Constraints
- At startup, the scheduler receives exactly one controller address (not a candidate list) and establishes its connection only to that address.
- Periodically issues the existing role-agnostic status query against that one connection; on an unreachable connection or a reported non-primary role, requests the scheduler process to exit.
- Never attempts any other controller address, and never re-derives a candidate list — this component structurally has no such list to fall back to.
- Also receives and acts on the graceful-exit notice (Requirement 2.2) via the existing request-handler dispatch, exiting within a bounded time.
Dependencies
- Outbound: the existing controller-scheduler wire connection/status query — P0.
- Inbound:
scheduler_req_handler's new graceful-exit case — P0.
Contracts: Service [x]
Service Interface
/* src/scheduler/include/sched_owner.h */
/* Starts the periodic ownership-watch loop against the single controller
* address this scheduler process was given at startup. Runs on its own
* thread (mirroring the worker daemon's existing checking_connection()
* cadence/shape), independent of the picker/allocator threads.
*/
int sched_owner_watch_start(const char *owner_host, uint16_t owner_port);
/* Called by the request handler when the graceful-exit notice arrives on the
* existing connection. Triggers the same exit path the periodic watch loop
* uses on a failed check.
*/
void sched_owner_request_exit(void);
- Preconditions:
owner_host/owner_portidentify the specific controller instance that spawned this process. - Postconditions: the process exits once either the periodic check fails or a graceful-exit notice is received — no other outcome resumes normal operation with a different controller.
- Invariants: never issues
xcecli_join/any wire call against any address other thanowner_host:owner_port.
Implementation Notes
- Integration: replaces
sched_needs_rejoin/sched_on_reconnect/sched_publish_check_rejoininscheduler.centirely — these are removed, not left dormant, since Requirement 3.4 makes their cross-controller-rejoin purpose permanently unreachable. - Risks: the periodic check interval trades detection latency against status-query load; finalize the exact interval during implementation, following the worker's existing
update_interval-style convention rather than inventing a new tunable pattern.
Wire Protocol
Graceful-Exit Wire Message
Event Contract
- Published by: the controller, from
scheduler_proc_stop's SlowLane action, over the existing controller-scheduler connection. - Subscribed by: the scheduler's existing
scheduler_req_handler(src/scheduler/src/handler.c), which invokessched_owner_request_exit(). - Delivery: best-effort over the existing persistent connection — if delivery fails (connection already gone),
scheduler_proc_stop's bounded wait-then-force-terminate path (Requirement 2.3) still guarantees the child does not outlive the timeout, andSchedOwnerWatch's independent periodic check (Requirement 3) is the backstop if the notice is lost. - Ordering/idempotency: at most one graceful-exit notice is ever sent per stop action (SlowLane dedup); receiving it more than once (e.g. a retry) is a no-op if the process is already exiting.
Data Models
No new persisted entity is introduced. The scheduler remains fully stateless with respect to durable storage — its in-memory job/queue/config state is always rebuilt from the controller's existing push traffic on join, unchanged by this feature (Requirement 6.3). The only new in-memory state is:
- Controller side: which scheduler child (if any) this controller instance currently owns, its process handle, and its last-progress timestamp — process-lifetime only, never persisted, rebuilt from scratch on controller restart (a restarted controller that is primary starts a fresh scheduler exactly as any other promotion would).
- Scheduler side: the single owning-controller address it was started with — process-lifetime only.
Since nothing here is written to or read from a durable log, snapshot, or checkpoint, the project's save/restore field-completeness review does not apply to this design.
Error Handling
Error Strategy
Every failure in scheduler-lifecycle management follows the project's existing fail-loud-stay-alive philosophy (ha-async-io.md, controller/CLAUDE.md): log an operator-visible error, never crash or hang the controller, and retry where retrying is meaningful.
Error Categories and Responses
- Scheduler fails to start (posix_spawn failure, immediate crash):
scheduler_proc_startlogs an error and retries on the controller's existing heartbeat-driven cadence while it remains primary (Requirement 1.5); the controller itself continues serving every other role unaffected (Requirement 4.4). - Scheduler does not exit within the graceful-exit timeout:
scheduler_proc_stopforce-terminates the child after the bounded wait; the controller's own demotion completes regardless (Requirement 2.3). - Scheduler connected but not progressing (wedged): treated as unresponsive once past the threshold, terminated and replaced (Requirement 5.3), with an operator-visible log (Requirement 5.4).
- Scheduler cannot reach or confirm its owning controller: the scheduler itself exits (Requirement 3.2, 3.3) — no error is reported back to the controller for this case, since the controller side detects the resulting disconnect via existing
handle_scheduler_leave()bookkeeping.
Monitoring
All new log lines (start failure, forced-kill-on-timeout, liveness-triggered restart) use the existing controller logging conventions (WARN/ERROR macros already used throughout ha.c) — no new logging mechanism is introduced.
Testing Strategy
Unit Tests
scheduler_proc_startsubmits exactly one SlowLane action per promotion and a second concurrent call while one is in flight is deduped (not double-submitted) — directly exercises Requirement 1.2/1.3 via the existingtest_slowlane.cdedup behavior.raft_cb_become_leadercallsscheduler_proc_startonly after every existing promotion-refusal gate returns eligible, and does not call it on any refusal path — regression-guards Requirement 1.1 against a future gate reordering.raft_cb_become_followercallsscheduler_proc_stopand completes its own role transition without waiting on that call's result — regression-guards Requirement 2.3.SchedOwnerWatch's periodic check exits the process on a simulated unreachable controller and on a simulated non-primary role response, and never attempts a second address — directly exercises Requirement 3.1-3.4.scheduler_proc_check_livenesstriggers a restart only when the last-progress timestamp exceeds the threshold while the connection remains open (not on a disconnect, which is a separate, already-detected case) — exercises Requirement 5.1/5.2.
Integration Tests
- A real 3-node controller cluster promotion results in a scheduler process appearing, joining, and receiving job/queue/config state, with no manual step — exercises Requirement 1.1-1.4 end to end.
- A graceful step-down (forced via the existing test-only demotion trigger) results in the prior primary's scheduler exiting within the bounded timeout, verified by process absence, not just a log line — exercises Requirement 2.1/2.2.
- A hard-killed primary (matching this session's original bug-1760 repro exactly) results in the orphaned scheduler self-exiting within its own periodic-check interval, and the newly-elected primary's fresh scheduler successfully dispatching a job submitted after the failover — exercises Requirement 3.1-3.5 and directly closes bug-1760.
- A scheduler artificially wedged (e.g. its progress-signal thread blocked) while its connection stays open is detected and replaced — exercises Requirement 5.1-5.3.
- A job dispatched by a scheduler that is then killed mid-decision (before its dispatch is durably recorded) remains available for the replacement scheduler to consider — exercises Requirement 2.4.
E2E / Soak Tests
- Extend
.claude/skills/xce-test-ha/run-ha-soak.sh's sequential and chaotic modes (already exercising controller failover) to assert a working scheduler is present after every round without any harness-side scheduler start/stop action — this replaces the manualstart_worker_and_scheduler()/restart_scheduler_for_new_primary()logic added this session as bug-1760's workaround, which becomes dead code once this feature ships. - A rapid sequence of promotions/demotions (chaotic mode's overlapping-kill pattern) produces no scheduler spawn/kill thrash beyond one scheduler per genuinely-promoted instance — exercises the SlowLane-dedup mitigation documented in research.md's Risks.
Migration Strategy
flowchart TB
Phase1[Phase 1 controller and scheduler code changes land behind no behavior change until wired]
Phase2[Phase 2 wire promotion and demotion hooks and pinned single controller startup]
Phase3[Phase 3 update xce deploy ha and xce test ha tooling to stop manually starting the scheduler]
Phase4[Phase 4 remove superseded rejoin logic and multi candidate scheduler startup path]
Phase1 --> Phase2 --> Phase3 --> Phase4
- Rollback trigger: if a real deployment depends on independently placing the scheduler on a dedicated host for resource-isolation reasons not previously surfaced, that is a genuine requirements gap discovered late — the fix is to pause before Phase 4 (superseded-logic removal) rather than force the migration through, since Phase 4 is the only phase that is hard to reverse cheaply.
- Validation checkpoint: after Phase 2, run the soak-harness scenario in Testing Strategy's E2E section before proceeding to Phase 3/4, since it is the closest thing to a direct regression test for bug-1760 itself.
Open Questions / Risks
- The exact wire opcode/schema shape for the graceful-exit notice is left to implementation (a small, additive
controller.capnpchange, following the same pattern recent specs have used for new controller-to-daemon messages) — this is appropriately an implementation detail, not a design decision, since no existing opcode fits (confirmed by inspection:OPCode_shutdownis an inbound admin-to-controller command, not a controller-to-scheduler one, and is not reusable here). - The precise "no scheduling progress" threshold and periodic-check interval (Requirement 5, Requirement 3) are implementation-time tuning decisions, not design decisions — see research.md Risks for the reasoning against inventing a bespoke synthetic heartbeat.
- Confirm during implementation whether any other in-tree consumer starts
xce_schedulerwith a multi-candidate--peerslist for a reason unrelated to this feature (e.g. a test fixture) that would need explicit review rather than silent breakage (carried over from research.md).