myxce/.kiro/specs/xce-node-manager/research.md
Rongsong Shen 177e9b292a
docs(xce-node-manager): add spec (requirements/design approved)
Successor to scheduler-primary-lifecycle: a new per-node xce-node-manager
component (one per host, role-assigned: manager/worker/apiserver/
reverse-proxy) owns starting/stopping XCE components for its node's
role -- including starting/stopping the scheduler on a manager node in
reaction to that node's controller reporting itself primary vs.
standby over a local heartbeat (Unix domain socket, client logic
shared via libxce) -- rather than the controller directly
spawning/killing its own scheduler process. Carries forward
scheduler-primary-lifecycle's underlying requirements (bug-1760's
reactive-rejoin deadlock, wedge detection, graceful shutdown,
controller-resilience invariants).

Design review (kiro-validate-design) fixed 3 issues before GO: a
uniform-single-OS-user assumption that was factually wrong given
xce-deploy-ha's real worker daemon runs as root (resolved: root only
on worker-role nodes); Requirement 4.3's wedge-detection mechanism
verified directly against the actual scheduler code
(sched_job_picker's cbuf_get() does an unbounded wait when idle,
confirming the naive design would false-positive -- fixed by swapping
to the existing cbuf_timedget() bounded-deadline variant); and
PortCheckMonitor's TCP connect made explicitly non-blocking to match
HeartbeatListener's own discipline. Tasks not yet generated.

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:38 +00:00

16 KiB

Research & Design Decisions

Summary

  • Feature: xce-node-manager
  • Discovery Scope: Complex Integration — new top-level daemon, no direct precedent, but heavily grounded in existing patterns across controller/worker/scheduler/apiserver and the AppImage packaging layer.
  • Key Findings:
    • tools/appimage/entry.sh is a thin per-role dispatcher (xce <role> args...) that execs exactly one of xce_controller/xcecd/xce_scheduler/xce_apiserver in the foreground — there is no existing orchestration layer to extend or conflict with, and haproxy (already bundled in the AppImage per build-deployment.md) has no dispatch case at all today, i.e. it is currently unreachable through entry.sh.
    • src/worker/src/process_worker_manager.c's existing process_manager_start_worker() spawns via raw fork()+execl(), not posix_spawn() — an already-shipped instance of the exact mutex-inheritance-deadlock hazard class product.md's locked decision ("no fork() for snapshots") and the superseded scheduler-primary-lifecycle design already ruled out. Not fixed here (out of scope, unrelated component) but explicitly NOT a pattern to imitate.
    • The controller already has a non-blocking, delaymgr-driven, role-aware periodic task (ha_start_primary_heartbeat/primary_heartbeat_fn, ha.c:9949) reading the atomic ha->role every tick — proof that a cheap, always-safe periodic local report is a well-trodden pattern in this codebase, though this specific function is gated on first-promotion (s_armed) and therefore unsuitable to piggyback on directly (see Decision: Heartbeat Call Site).
    • src/configs/hosts.yaml (the cluster's existing per-host config, distributed via OPCode_replConfigPush) describes scheduler-visible compute-host attributes, not deployment/process-placement roles — confirms no existing config surface for "which node runs which XCE component," and that any such config cannot be cluster-distributed (a node's role must be knowable before any controller is reachable).

Research Log

Existing multi-component launch/packaging surface

  • Context: Before designing a new orchestrator, check whether one already exists in disguise (AppImage bundling, systemd units, an existing launcher script).
  • Sources Consulted: tools/appimage/entry.sh, build-deployment.md (AppImage/Production Artifacts sections), xce-deploy-ha SKILL.md, xce-test-ha/run-ha-soak.sh.
  • Findings: The AppImage bundles all daemons plus haproxy behind one xce <role> entry point, but entry.sh only launches a single named process in the foreground per invocation — no supervision, no multi-process orchestration, no config-driven role resolution. The local dev/test harness (xce-deploy-ha) generates one shell script per component (start.sh, start-worker.sh, start-scheduler.sh), started manually/independently — the same "independently-started component" assumption scheduler-primary-lifecycle's requirements already flagged, now generalized to worker/apiserver too.
  • Implications: xce-node-manager is additive, not competing with an existing mechanism. The natural integration point is one new entry.sh case (manager) that execs the new xce_nodemgr binary; xce_nodemgr itself then resolves and spawns the other bundled binaries directly (same which xce_controller-style PATH resolution entry.sh already uses), including finally wiring up the previously-unreachable bundled haproxy for the reverse-proxy role.

Controller-side periodic reporting hook

  • Context: Where should the Controller's "I'm alive / I'm primary or standby" report be triggered from, without touching the promotion/demotion callback functions those steering docs flag as the riskiest, least-covered code in the controller?
  • Sources Consulted: ha.c (primary_heartbeat_fn, ha_start_primary_heartbeat, raft_cb_become_leader/raft_cb_become_follower — via codegraph_explore), ha-async-io.md.
  • Findings: primary_heartbeat_fn is armed only once, on a controller instance's first promotion (s_armed guard) — a controller that has never won an election never runs it, which would leave xce-node-manager unable to distinguish "controller alive, still standby" from "controller process is down" on any node that hasn't yet been promoted. raft_cb_become_leader/raft_cb_become_follower themselves are flagged with no direct test coverage and are the single most call-dense, safety-critical functions in the controller (122+ calls from raft_cb_become_leader alone).
  • Implications: The report must come from a small, independent, unconditionally-armed periodic point tied to the Controller's own process lifetime (not gated on promotion), reading the same atomic ha->role field primary_heartbeat_fn already reads. This adds zero new logic to raft_cb_become_leader/raft_cb_become_follower — a strict reduction in touched surface area compared to the superseded spec, which called full spawn/monitor orchestration from inside raft_cb_become_leader directly.

Scheduler wedge-detection signal shape

  • Context: The superseded spec's Requirement 5 (carried forward as 4.3 here) requires detecting a scheduler that is connected but not making real scheduling progress — a bare "I'm alive" ping cannot distinguish a wedged decision-loop from a healthy one if the two run on different threads.
  • Sources Consulted: In-conversation review of scheduler-primary-lifecycle's design.md (SchedulerConnectionBookkeeping's last-progress-timestamp approach, fed from real dispatch/pend-reason traffic, not a synthetic heartbeat); bug-1760's own root-cause text describing the scheduler's outbound wire traffic as reactive/idle-silent ("the scheduler has nothing queued to publish").
  • Findings: Reusing the scheduler's reactive wire-publish call as the heartbeat trigger would reintroduce the exact idle-silence failure mode bug-1760 already exposed. The correct trigger is the scheduler's own internal round/picker loop iteration — which must tick on a bounded cadence regardless of whether a round finds any job to act on — not the wire-publish call.
  • Implications: One wire-format field (a monotonically increasing sequence number) serves double duty: for the Controller/Worker/Apiserver it is a bare liveness counter; for the Scheduler, since it is incremented once per round-loop iteration (not once per successful publish), its advancement is the real-progress signal Requirement 4.3 needs, with no separate progress field required. See Decision: Unified Heartbeat Wire Format.

Architecture Pattern Evaluation

Option Description Strengths Risks / Limitations Notes
Adopt a generic external supervisor (systemd user units, s6, runit, supervisord) for all four roles Delegate process start/restart entirely to an existing, battle-tested tool Zero new code for the generic "keep running" case (Requirement 2) Cannot express Requirement 3's Controller-role-driven conditional scheduler start/stop or Requirement 4.3's progress-aware wedge detection — those are XCE-domain-specific decisions no generic supervisor can make; also conflicts with the AppImage's self-contained, zero-external-dependency portability goal (product.md) across arbitrary air-gapped target hosts that may not run systemd Rejected as the primary mechanism; the generic host service manager is still the right tool for the one thing left over — restarting xce-node-manager itself, already scoped out of this feature (Requirement 7 boundary)
Custom xce-node-manager daemon using only POSIX primitives (posix_spawn, Unix domain datagram sockets), consistent with the already-reviewed scheduler-primary-lifecycle precedent Build a small, purpose-specific supervisor Can express both the generic and Controller-role-driven policies; no new external runtime dependency; reuses the posix_spawn decision already vetted against the mutex-inheritance-deadlock hazard New component to build and test Selected

Design Decisions

Decision: Heartbeat call site is each component's own natural loop iteration, not a new thread

  • Context: Requirements 4.1 and 4.3 require every managed component to report liveness, and require the Scheduler's report specifically to reflect real progress.
  • Alternatives Considered:
    1. A dedicated heartbeat-sender thread per component, independent of its main work loop — simplest to bolt on, but structurally cannot distinguish a wedged work thread from a healthy one (the sender thread keeps reporting regardless).
    2. Piggyback the Controller's report on the existing primary_heartbeat_fn — rejected, see Research Log (promotion-gated arming leaves never-yet-primary controllers silent).
  • Selected Approach: Each component calls a shared libxce heartbeat primitive once per iteration of its own existing loop (the Controller's iomgr/delaymgr-driven event loop, the Scheduler's round loop, the Worker's ipc_worker_run/usleep loop); the Apiserver, having no persistent loop of its own (a request-driven Gin server), uses a small dedicated ticker goroutine instead — the one place a new "thread" is unavoidable, since there is no existing loop to hook into.
  • Rationale: Guarantees the report can only advance when the component's own real work-loop is actually turning, which is what makes the same signal double as both liveness and (for the Scheduler) progress. The Scheduler's live driver (scheduler.c, sched_job_picker) is the correct call site, not the pure decision engine in core.c/core_events.c/core_session.c/core_clock.c — the scheduler-core spec's own core/driver separation already keeps the core free of controller I/O, and this feature's local heartbeat is new outside-facing I/O that belongs on the same side of that boundary as the existing xcecli connection. The Scheduler's separate simulation-mode driver (sim.c) gets no heartbeat call site at all and stays entirely outside xce-node-manager's awareness (see Non-Goals in design.md) — it is a standalone, manually-invoked tool with no Controller or primary/standby concept.
  • Trade-offs: Slightly different integration shape per component (loop hook vs. ticker) instead of one uniform mechanism; acceptable since the underlying wire format and libxce primitive are identical either way.
  • Follow-up resolved during design review (not deferred to implementation): codegraph_explore confirmed sched_job_picker calls cbuf_get() (src/lib/mylock.c:144), an unbounded pthread_cond_wait when the job queue is empty — a heartbeat placed naively at the top of that loop would never fire during genuine idle periods, causing xce-node-manager to falsely treat a healthy idle Scheduler as wedged and kill it. Fix: swap to cbuf_timedget() (same file, an existing bounded-deadline variant already used elsewhere in the codebase) with a bounded idle-tick deadline, and call the heartbeat primitive on both the real-job path and the timeout path.

Decision: Unified heartbeat wire format, self-identifying payload, no CGO for the Apiserver client

  • Context: The heartbeat protocol must be usable by four component types across two languages (C daemons, Go apiserver) over one shared local socket, with the receiving xce-node-manager able to attribute each datagram without relying on socket-level peer identity (a SOCK_DGRAM Unix socket exposes no reliable peer address unless the sender also binds, which adds needless coupling).
  • Alternatives Considered:
    1. Route the Apiserver's heartbeat through libxce via the existing CGO bridge, matching how apiserver's C-tier calls already work.
    2. A small, independently-implemented Go client speaking the identical wire format natively, with no CGO involved.
  • Selected Approach: One canonical fixed-layout binary wire format (magic/version, role, status, pid, sequence number — no Cap'n Proto), implemented once in libxce for the three C daemons (which already link it natively, zero extra cost), and once as a small native Go implementation for the Apiserver.
  • Rationale: The payload is a fixed, trivial, few-field structure — reimplementing it natively in Go is a handful of lines with negligible drift risk, and avoids pulling a CGO dependency into a code path (a periodic status ticker) that has no other reason to touch the C tier; tech.md already documents that Apiserver's own unit tests deliberately run CGO_ENABLED=0, and only the existing backend bridge work requires the CGO build. The payload self-identifies its sender (role + pid fields), so the receiver never depends on socket-level peer identity. This is a deliberate, narrow exception to structure.md's "Cap'n Proto for wire formats" convention: that convention targets the real cross-host, cross-version network protocol (controller.capnp/c.capnp), where schema evolution and cross-language generated bindings earn their cost; this protocol is same-host-only, always emitted and consumed by binaries from the same build (Node Manager and the components it manages ship together), and confirmed (go.mod/go.sum inspection) that the Go apiserver carries no existing capnp dependency at all — adopting Cap'n Proto here would mean a new Go dependency purely for a fixed 4-field local datagram, which the Simplification lens rejects as disproportionate.
  • Trade-offs: Two implementations of the same format instead of one; mitigated by the format's small size and by both being validated against the same contract in this design.
  • Follow-up: none — the format is fixed at design time (see Data Models in design.md).

Decision: Node Manager privilege is role-conditional (root only on worker-role nodes)

  • Context: xce-deploy-ha's own tooling documents that the real (non-virtual) Worker daemon xcecd runs as root ("plain kill can't touch it," needs sudo) — discovered during design review, not anticipated during requirements. A uniform single-OS-user assumption for xce-node-manager (as first drafted) would leave it unable to start/stop/signal xcecd on a real worker-role node.
  • Alternatives Considered:
    1. xce-node-manager always runs as root everywhere, regardless of role.
    2. xce-node-manager stays unprivileged everywhere; grant it a narrow Linux capability or scoped sudo rule for the worker case specifically.
    3. Treat real (root-requiring) worker management as out of scope for this spec; only support xcecd_virtual.
  • Selected Approach (user decision): xce-node-manager runs as root only on a node whose assigned role includes worker; on manager/apiserver/reverse-proxy-only nodes it runs as an ordinary service account. This is fixed by the Node Role Manifest at Node Manager startup, not a runtime toggle.
  • Rationale: Matches xcecd's own pre-existing requirement exactly, with no new privilege-escalation surface beyond what that daemon already needs; simpler than a capability-based scheme, and avoids narrowing this spec's worker-role support to the virtual test double only.
  • Trade-offs: The heartbeat socket's permission model is now per-node-role rather than one uniform story (see design.md Security Considerations) — an implementer must not assume a single OS user across all node roles.
  • Follow-up: none — fixed at design time.

Risks & Mitigations

  • A node-manager crash on a manager node strands its Controller's Scheduler with nothing left to stop it on a subsequent demotion — mitigated by retaining a scheduler-side independent controller-liveness check as a backstop (Requirement 6), so the Scheduler never depends solely on xce-node-manager staying alive.
  • xce-node-manager restarting could double-spawn a component that is already running — mitigated by a small persisted per-role record (role, pid, start time, binary path) it consults on startup before spawning (see design.md Data Models); a stale/mismatched record falls back to a fresh start rather than blindly trusting the file, and each component's own listen-port bind-failure is a natural backstop against an accidental true duplicate.
  • Sizing the heartbeat interval/timeout thresholds is an implementation-time tuning decision (same posture the superseded spec already took for its own liveness thresholds), not a design decision.

References

  • .kiro/specs/scheduler-primary-lifecycle/design.md — superseded design; source of the posix_spawn mutex-inheritance-deadlock finding and the wedge-detection real-progress principle, both carried forward here.
  • .wolf/buglog.json bug-1760 — motivating incident for the scheduler-lifecycle requirements this spec restates.