myxce/.kiro/specs/scheduler-primary-lifecycle/research.md
Rongsong Shen 0f7699deba
docs(scheduler-primary-lifecycle): add superseded spec (requirements/design/tasks)
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
2026-09-03 09:13:01 +00:00

15 KiB

Summary

  • Feature: scheduler-primary-lifecycle
  • Discovery Scope: Extension (existing controller + scheduler components; no new daemon type, no new external dependency)
  • Key Findings:
    • slowlane_submit() (src/controller/slowlane.c) already exists, is tested, and is the exact primitive this feature needs: a bounded, admission-capped executor with per-key in-flight dedup that runs submitted work off the Raft drive threads (iomgr/delaymgr). No new async-execution mechanism needs to be built.
    • XCE_CONTROLLER_HOOK_SCHEDULER / HOOK_DETAIL_SCHEDULER_JOIN / HOOK_DETAIL_SCHEDULER_DEAD (src/include/xce/cluster_def.h) are declared but never fired or consumed anywhere in src/controller/*.c — confirmed by exhaustive grep. They are dead enum values, not existing plumbing to build on.
    • The controller already tracks scheduler connection lifecycle via on_scheduler_join() / handle_scheduler_leave() (src/controller/sched.c) and a single cluster_t.sched.ep slot — this is reusable bookkeeping for "is my child scheduler currently joined," but it has no concept today of owning the scheduler process itself.
    • The scheduler's current rejoin logic (sched_needs_rejoin/sched_on_reconnect/sched_publish_check_rejoin, src/scheduler/src/scheduler.c) and its use of the general-purpose, candidate-rotating client library (xcecli_join, XCECLI_FAILOVER_CALL) both become dead weight under the new model and should be removed, not left dormant — the requirements (3.4) explicitly forbid the scheduler from ever trying another controller.
    • ha_dispatch_gate() (src/controller/ha.c:4573) already rejects scheduler write opcodes against a non-primary controller (CTRL_STANDBY's allow-list excludes job dispatch / pend-reason). Confirmed by reading the gate's full switch statement. This means the safety argument for this feature is already true today — the feature is solely about availability, not correctness.

Research Log

Where does controller promotion actually complete?

  • Context: Need the concrete integration point for "start a scheduler on promotion."
  • Sources Consulted: src/controller/ha.c, raft_cb_become_leader (line 1615), read via codegraph.
  • Findings: raft_cb_become_leader is long and gated: election-timeout cancellation, safe-committed-LSN computation, first-boot cluster_init_phase2, WAL truncation-floor handling, then a sequence of promotion-refusal gates (recovery-truncated, apply-divergent, self-restart-pending, apply-resource-fenced, config-serve-fenced) that each return without completing promotion, staying alive per the project's never-self-terminate invariant. Only after every gate passes does the function proceed to authoritative promotion (role flip to CTRL_PRIMARY, ha_start_primary_heartbeat, repl_add_standby for reachable voters).
  • Implications: The scheduler-start action must be triggered only at the tail of this function, after all refusal gates have passed — never speculatively earlier. A promotion attempt that gets refused by any gate must not start a scheduler.

Where does demotion happen?

  • Context: Need the integration point for "signal my scheduler to exit."
  • Sources Consulted: src/controller/ha.c, raft_cb_become_follower (line 2120), read via codegraph.
  • Findings: raft_cb_become_follower is the Raft library's on_become_follower callback, invoked from multiple paths (losing an election, observing a higher term, an append-entries request from a legitimate newer leader). This is the single funnel for every graceful role-loss case named in Requirement 2.1 (voluntary step-down and higher-term discovery both route through it).
  • Implications: One hook site covers all of Requirement 2's graceful-demotion triggers; no separate handling needed per trigger type.

Is there an existing async-safe way to do blocking work from the HA path?

  • Context: .kiro/steering/ha-async-io.md's thread-role contract forbids blocking work on iomgr/delaymgr, and spawning/signaling/monitoring a child process is unavoidably blocking-ish (fork/exec, waitpid, kill).
  • Sources Consulted: src/controller/slowlane.c, src/controller/include/slowlane.h, existing callers in ha_convergence.c, config_repl.c, persist.c.
  • Findings: slowlane_submit(sl, key, fn, arg) already exists for exactly this purpose ("long blocking I/O off the Raft drive threads"), is non-blocking to the caller, runs fn on a dedicated worker thread with no lock held, and provides per-key in-flight dedup (-EEXIST on a duplicate submit of the same key while one is still running/queued). A test_slowlane.c suite already covers non-blocking submission, dedup, and admission-cap behavior.
  • Implications: Build vs. Adopt — adopt. Scheduler start/stop/monitor actions are ordinary slowlane_submit() work items. The dedup key (scoped to "this controller's scheduler-management action") mechanically satisfies Requirement 1.2/1.3 ("at most one Scheduler Process," "shall not start a second") as a side effect of reusing an already-tested primitive, with no new synchronization to design or verify.

Is there an existing pattern for one XCE daemon supervising a child process?

  • Context: Wanted to know if process_worker_manager.c/uv_ipc.c (the worker daemon's job-process supervision) is directly reusable.
  • Sources Consulted: src/worker/src/process_worker_manager.c, src/worker/src/uv_ipc.c (libuv-based named-pipe IPC with a heartbeat callback between the worker daemon and its forked job-executor children).
  • Findings: The pattern (fork a child, communicate over a local IPC channel, monitor a heartbeat) is directly analogous to what this feature needs between a controller and its scheduler child. The concrete code, however, is tightly coupled to job-execution concerns (cgroups, dbus, job exit-status plumbing) and to libuv, a dependency the controller does not currently carry.
  • Implications: Build vs. Adopt — adopt the pattern, not the code. The controller gets its own small, purpose-built scheduler-process-manager component (fork/exec + reap + a liveness signal), following the same shape (parent owns child lifecycle, detects child exit, watches liveness) without pulling in libuv or the worker's job-execution-specific machinery. Simplification lens: a shared abstraction with the worker's job-process manager was considered and rejected — the two "child processes" (a job executable vs. a companion XCE daemon) differ enough (job exit codes/signals/cgroups vs. a single well-known daemon binary with its own wire protocol) that forcing a shared interface would add an abstraction layer with only one real implementation on each side.

How does the scheduler detect its own controller is dead or no longer primary?

  • Context: Requirement 3 needs an active detection mechanism, not just reliance on OS-level parent-death propagation (discussed and explicitly not relied upon as the primary mechanism, since it doesn't cover "controller process dies while the host lives").
  • Sources Consulted: src/lib/client.c (xcecli_join, XCECLI_FAILOVER_CALL), existing ctrlstatus/OPCode_ctrlstatus read-only status query (confirmed answerable in every controller role per ha_dispatch_gate), src/controller/sched.c.
  • Findings: The scheduler already opens a persistent, Cap'n-Proto-framed TCP connection to a controller and already has a role-agnostic status query (ctrlstatus) available in every role. Connection loss (RDHUP) is already detected by the existing transport layer everywhere else in this codebase (workers, clients) — nothing new needs to be built to notice "my controller is unreachable." What is missing is (a) pinning the scheduler to exactly one controller address with no candidate list/rotation, and (b) a periodic check of that one controller's reported role, so a controller that demotes without the connection ever dropping (the common case — TCP stays up across a graceful step-down) is still detected.
  • Implications: Build vs. Adopt — adopt the existing wire protocol and status query; build only the pin-to-one-controller startup mode and the periodic self-check loop (a much smaller version of the worker's checking_connection() shape, minus any rejoin/rotation behavior). The explicit graceful-exit signal (Requirement 2) rides the same existing connection as a new, small message case in the scheduler's existing request handler (scheduler_req_handler, src/scheduler/src/handler.c) rather than a separate channel.

Architecture Pattern Evaluation

Option Description Strengths Risks / Limitations Notes
Controller-owned child process (selected) Primary controller forks/execs a scheduler bound to itself; scheduler self-exits on losing that specific controller Reuses controller's already-hardened Raft election for "who's authoritative"; host death handled for free; no new election/lease protocol Controller gains a new, bounded process-supervision responsibility; scheduler loses independent host placement Matches decision reached in requirements discussion
Independent scheduler + periodic reconnect (rejected) Keep scheduler as today, add an unconditional periodic xcecli_join() retry (worker-style checking_connection()) Smaller code change; preserves independent deployment Does not solve scheduler-host-death or wedged-scheduler detection; still needs a second mechanism for those Considered and superseded once host-death handling became an explicit requirement
Standby schedulers + controller-granted lease (rejected) Multiple scheduler processes compete for a renewable lease from the primary controller Handles wedge/host-death without host co-location; scheduler keeps independent placement A second, parallel authority/liveness protocol to design, build, and prove correct, duplicating the controller's own Raft election in spirit Rejected in favor of piggybacking on existing Raft election (simplification lens)
General per-host role-manager agent (deferred) A new component per host starts/stops worker/controller/scheduler/apiserver by declared role and cluster state Generalizes beyond just scheduler New always-on process whose own supervision is unsolved; would need to watch controller election state itself, adding latency and an indirection layer Explicitly deferred in requirements as a separate, independently-motivated initiative, not scope for this spec

Design Decisions

Decision: Use slowlane_submit() for all scheduler process management

  • Context: Starting, monitoring, and signaling a child process from raft_cb_become_leader/raft_cb_become_follower must not block the Raft drive threads.
  • Alternatives Considered:
    1. Spawn a brand-new dedicated pthread per promotion — works, but reinvents admission control and dedup that already exist.
    2. Do the fork/exec inline in the Raft callback — violates the thread-role contract outright (fork/exec/waitpid are blocking).
  • Selected Approach: Submit scheduler-management actions (start on promotion, signal-and-reap on demotion, restart on detected wedge) to the existing SlowLane executor with a per-controller-instance dedup key.
  • Rationale: Already built, already tested, already the established pattern for exactly this class of problem in this codebase (raft-drive-async spec).
  • Trade-offs: None material — this is a pure reuse with no new abstraction.
  • Follow-up: Confirm the SlowLane instance's max_inflight/n_workers sizing (set at slowlane_init() call sites in cluster.c) has headroom for scheduler-management tasks alongside its existing consumers (peer connect, snapshot transfer, config-push); this is a sizing check, not a new design.

Decision: Scheduler pins to exactly one controller address at startup, no candidate list

  • Context: Requirement 3.4 forbids the scheduler from ever joining or continuing to operate against any controller other than the one that started it.
  • Alternatives Considered:
    1. Keep the existing --peers full-candidate-list startup mode and just suppress rotation in the failover-call path — leaves a footgun where the shape of the config still implies "any of these are fine."
    2. Give the scheduler a single-controller startup mode entirely, with no candidate list construct at all.
  • Selected Approach: Option 2. The controller passes its own address (not the full peer list) when it starts its scheduler.
  • Rationale: Makes the "one controller owns one scheduler" invariant structurally true from the scheduler's own configuration, not just an unenforced behavioral convention.
  • Trade-offs: The scheduler's startup contract changes (breaking change to its CLI/config surface); existing deployment tooling that passes a full peer list to xce_scheduler must be updated (see Requirement 6 / Integration & Migration Notes in design.md).
  • Follow-up: Confirm during implementation whether any other in-tree consumer starts xce_scheduler with a multi-candidate list for a reason unrelated to this feature (e.g. a test fixture) — such usages need explicit review, not silent breakage.

Risks & Mitigations

  • Risk: A rapid succession of promotions/demotions (chaotic failover) could thrash scheduler process spawn/kill cycles. — Mitigation: the SlowLane dedup key naturally coalesces overlapping start requests for the same controller instance into at most one in-flight action; a demotion always signals-and-reaps before that controller instance could ever legitimately be promoted again (a demoted node must win a fresh election first).
  • Risk: The wedged-scheduler health check (Requirement 5) needs a "making progress" signal, not just an open connection — inventing a bespoke internal probe risks becoming its own source of false positives. — Mitigation: reuse the existing job/queue/config push traffic the controller already sends its scheduler as the progress signal (e.g., confirmation that a recent push was acted on), rather than adding a new synthetic heartbeat payload; finalize the exact signal during implementation against real traffic patterns.
  • Risk: Existing tooling and specs (xce-deploy-ha, xce-test-ha's soak harness, any other spec assuming an independent, reconnecting scheduler) silently drift out of sync with the new model. — Mitigation: explicit File Structure Plan entries in design.md for every piece of tooling identified as depending on today's model (see design.md Integration & Migration Notes).

References

  • .wolf/buglog.json bug-1760 — original live repro motivating this spec.
  • .kiro/specs/raft-drive-async/design.md — original SlowLane design (cited verbatim in slowlane.h's own header comment).
  • .kiro/steering/ha-async-io.md — thread-role contract and never-self-terminate invariant this design must respect.