# Brief: scheduler-framework ## Problem The XCE scheduler has a plugin architecture but critical gaps prevent it from being a real framework: 1. **Job selection policy is hardcoded** — `sched_job_picker()` in `scheduler.c:39` uses the literal string `"xce_sched_fcfs"`. The picker runs in a dedicated thread with `jbuf` cbuf communication. No way to swap policies without recompilation. 2. **Parallel job allocation is a stub** — `parjob_allocator.c:my_alloc()` returns 0 with no logic. Jobs requiring multiple slots (`maxTasks > 1`) produce no allocation. 3. **Allocation is one-shot** — `sched_make_allocation()` tries the first matching host and stops. No mechanism to try alternative allocation plans (different host×slot combinations) when the first fails. 4. **No preemption** — when a high-priority job cannot be scheduled due to insufficient free resources, there is no mechanism to preempt lower-priority running jobs to free resources. 5. **No project-level scheduling** — `xce_sched_project_t` is just `{ char* name }`. Projects have no resource ownership, quota, or scheduling policy. 6. **Monolithic scheduler** — the scheduler is tightly coupled to controller I/O (capnp, `apictx`). No way to replay recorded events for debugging or test scheduling policies without a live cluster. Operators need configurable scheduling policies, parallel job support, preemption, and a way to debug/tune scheduling behavior without a live cluster. ## Current State ### What exists - **Plugin framework** (`plugin.h`, `plugin.c`): `xce_sched_plugin_t` with 9 hooks + `job_elect_fn` / `job_alloc_fn`. Plugins registered in key-value map by name. - **FCFS job picker** (`fcfs.c`): dedicated `jobpicker` thread. Iterates queues by priority, jobs within queue by (priority desc, queue_time asc). Communicates picked jobid via `jbuf` cbuf. - **Seqjob allocator** (`seqjob_allocator.c`): handles `maxTasks == 1`. Checks shared resources → collects workers → tries one host → returns first match. - **Parjob allocator stub** (`parjob_allocator.c`): registered but `my_alloc()` is empty. - **Resource request types** (`resreq.h`): PRIMARY (minTasks/maxTasks/select/rusages/span/same/affinity), COMPOUND (list of primaries), ALTER (list of compounds). - **Select expression AST** (`resreq.h`): full parser/evaluator with per-session memoization (`select_eval_ctx_t`) and inter-session GC. - **Shared resources** (`xce_scheduler_t.sharedInst`/`sharedInUse`): zarray of resource values tracked per scheduling session. - **Scheduling loop** (`scheduler.c:sched_allocator()`): pick job → `sched_make_allocation()` → pend or dispatch → next. - **Controller integration** (`handler.c`): receives capnp push/notify → enqueues `xce_sched_update_request_t` → `sched_update()` dequeues. Decisions published via `sched->apictx`. ### What's missing - Configurable job picker: the plugin name is a hardcoded string literal - Parallel job allocation: no combinatorial plan generation, no multi-slot host matching - Iterator/lazy generation: allocation is eager one-shot, no retry with alternative plans - Preemption: no mechanism to preempt lower-priority running jobs - Project-level scheduling: `xce_sched_project_t` is just `{ char* name }` - Cross-job resreq optimization: no mechanism to skip already-failed resreq patterns within a session - Scheduler simulation/debugging: no way to replay recorded events, no virtual clock, no deterministic mode - Core/driver separation: scheduling logic is tightly coupled to controller I/O ## Desired Outcome ### Three Architectural Foundations **1. Pluggable Job Picker** — the core scheduling loop calls `picker->next_job()` to get the next schedulable job, with zero knowledge of how jobs are selected. The default `builtin_fcfs` picker internally implements queue-priority ordering and FCFS job ordering, replicating today's behavior exactly. Per-queue picker overrides are supported. The picker runs synchronously in the core's scheduling thread — no dedicated picker thread, no `jbuf` cbuf. The picker plugin interface (`xce_job_picker_iface_t`): | Category | Hooks | Purpose | |----------|-------|---------| | Lifecycle | `init`, `fini` | Setup and teardown | | State-change | `on_queue_update`, `on_job_push`, `on_job_state_change`, `on_job_update` | Called by core when events arrive from driver | | Session | `begin_session`, `next_job`, `end_session` | Scheduling round lifecycle | | Post-allocation | `on_dispatch`, `on_pend` | Notification after allocation attempt | | Optional | `on_config`, `on_job_rusage` | Advanced pickers (NULL if unused) | **2. Iterator-Driven Allocation** — lazy `xce_alloc_plan_iter_t` (candidate (nhosts, slots_per_host) plans, most-preferred first) composed with lazy `xce_host_iter_t` (candidate hosts for a given plan). `parjob_allocator` implements real combinatorial multi-slot allocation. Session-level unsatisfiable resreq fingerprint cache avoids re-evaluating already-proven-unsatisfiable resreqs. **3. Core/Driver Separation** — the scheduler core is a passive, event-driven engine with zero controller I/O dependencies. The driver connects it to the outside world: | Mode | Driver | Input | Output | |------|--------|-------|--------| | Normal | Controller-connected | capnp push/notify → core events | Core decisions → controller | | Simulation | Replay file | JSONL event file → core events | Core decisions → log (with `--sim-verify` diff against recorded) | - **Discrete event-driven virtual clock**: jumps directly to next event timestamp, no fixed tick, monotonic - **Snapshot + delta replay**: load state snapshot, replay only subsequent delta events - **Deterministic guarantee**: same initial state + same events = same decisions - **Runtime recording control**: xceadmin `scheduler record start/stop` commands - **JSONL replay format**: one event per line, debuggable, plain text - **What-if branching**: load snapshot, replay alternative delta events ### Queue vs Project — Roles | | Queue | Project | |---|---|---| | **Role** | Scheduling mechanism | Resource ownership | | **Question** | HOW are jobs ordered and constrained? | WHO owns resources and gets charged? | | **Scope** | One hardware/scheduling profile | Cross-queue, cross-cluster | | **Key attributes** | Priority, limits, time windows, per-queue picker override, preemption config | Quota, usage, hierarchy, members (future) | | **Cardinality** | 1 job → 1 queue, many-to-many | 1 job → 1 project, many-to-many | One project submits to many queues (different hardware profiles). One queue receives from many projects (shared hardware). The picker plugin may use `queue->project_name` internally for project-scoped iteration (v2). ## Alternatives Considered ### Approach A: Incremental Enhancement (rejected) | Aspect | Detail | |--------|--------| | **Job selection** | Add `job_pick_plugin` config field, same as chosen approach | | **Parallel jobs** | Implement parjob allocator with pre-computed plan list. Eager generation | | **Iterator** | No new abstraction. Simple for-loop over plan array | | **Shared resreq** | `xlist_t unsatisfiable_resreqs` in session state | **Rejected because:** Eager plan generation causes memory pressure for large combinatorial spaces. No reusable abstraction — each new allocator reimplements the loop. Short-term win, long-term rework cost. ### Approach B: Iterator-Driven Allocation + Picker Plugin ★ Chosen | Aspect | Detail | |--------|--------| | **Job selection** | Pluggable `xce_job_picker_iface_t` with `next_job()`. Single `builtin_fcfs` default. Per-queue picker override. Synchronous, no dedicated thread. | | **Iterator abstraction** | `xce_alloc_plan_iter_t` + `xce_host_iter_t` — lazy, cancel-safe, composable | | **Parallel jobs** | Combinatorial plan generation via plan iterator: 1host×N → 2hosts×⌈N/2⌉ → … → Nhhosts×1 | | **Host iterator** | Per-plan lazy candidate host iteration using existing select expression eval | | **Preemption** | `XCE_DECISION_PREEMPT` decision type, LSF-style cross-queue victim selection, SIGSTP via controller→worker | | **Shared resreq** | Session-level fingerprint cache: `{select, rusage, user, queue, project}` | | **Core/driver split** | Passive event-driven core + dual-mode driver (normal/simulation) | | **Simulation** | JSONL replay, snapshot + delta, discrete event-driven virtual clock, deterministic | **Chosen because:** Lazy generation avoids memory pressure. Single `next_job()` interface makes pickers truly pluggable — the core loop never changes. Core/driver separation enables simulation mode for debugging/tuning without a live cluster. Iterator composition matches the user's vision: plan iterator → host iterator → try alloc. ### Approach C: Full Scheduler Framework (deferred) | Aspect | Detail | |--------|--------| | **Job selection** | Policy chain — multiple plugins score/veto jobs | | **Iterator** | Triple composition: job iterator → plan iterator → host iterator | | **Resreq registry** | Named primary resreqs registered globally, reference-counted | | **Allocation plugins** | Different strategies as plugins: greedy, bin-packing, round-robin | | **Session state machine** | Formalized STAGING → ELECT → ALLOCATE → COMMIT | **Deferred (not rejected):** The policy chain and resreq registry are valuable but lack current demand. Approach B's picker interface is designed to compose upward — a future policy-chain picker implements the same `next_job()` interface. ### Decision Rationale Approach B chosen over A because the iterator abstraction pays off immediately (parjob combinatorial allocation) and compounds (every future allocator reuses it). The picker plugin interface with `next_job()` keeps the core loop simple (5 lines) and makes pickers truly pluggable. Core/driver separation enables simulation mode — a capability that would be prohibitively expensive to retrofit later. **Design evolution (2026-07-10 → 2026-07-27):** The original design specified a four-level architecture with three separate builtin plugins (`builtin_flat`, `builtin_strict_priority`, `builtin_fcfs`) and a three-level `project→queue→job` loop hardcoded in the core. Analysis of the existing FCFS plugin (`fcfs.c`) showed it already encapsulates all picking logic internally. The architecture was simplified to a single `next_job()` plugin interface with one builtin picker (`builtin_fcfs`) — the three-level decomposition is now an internal implementation detail of the plugin, not the core loop. Core/driver separation was added (Req 10) to enable simulation mode for policy debugging and tuning. ## Approach **Iterator-Driven Allocation with Pluggable Picker + Core/Driver Separation**: ### Core scheduling loop ```c void* sched_allocator(void* arg) { while (1) { picker->begin_session(picker); xce_sched_job_t* job; while ((job = picker->next_job(picker)) != NULL) { if (try_allocate(job) > 0) { picker->on_dispatch(picker, job->jobid); emit_decision(DISPATCH, job); } else if (can_preempt(job)) { emit_decision(PREEMPT, job, victims); picker->on_pend(picker, job->jobid); } else { emit_decision(PEND, job); picker->on_pend(picker, job->jobid); } } picker->end_session(picker); sleep(1); } } ``` The core loop has zero knowledge of project/queue/job ordering — all selection logic lives behind `next_job()`. ### Picker plugin interface ```c typedef struct xce_job_picker { // Lifecycle int (*init)(void* picker, xce_sched_core_t* core); void (*fini)(void* picker); // State-change hooks (called by core when events arrive) int (*on_queue_update)(void* picker, xce_sched_queue_t* queue); int (*on_job_push)(void* picker, xce_sched_job_t* job); int (*on_job_state_change)(void* picker, xce_sched_job_t* job, uint32_t state); int (*on_job_update)(void* picker, xce_sched_job_t* job); // Session lifecycle int (*begin_session)(void* picker); int (*end_session)(void* picker); // Job selection xce_sched_job_t* (*next_job)(void* picker); // Post-allocation notification int (*on_dispatch)(void* picker, uint64_t jobid); int (*on_pend)(void* picker, uint64_t jobid); // Optional hooks (NULL = not implemented) int (*on_config)(void* picker, xce_conf4sched_t* config); int (*on_job_rusage)(void* picker, xce_sched_job_t* job, xce_job_rusages_t* rusages); } xce_job_picker_iface_t; ``` ### builtin_fcfs internal design The default picker internally implements the equivalent of today's selection: ``` builtin_fcfs.next_job(): Iterate queues by priority (descending) Skip queues with no ready jobs For each queue: If queue has per-queue picker override: Delegate to that picker Else: Iterate jobs by priority desc, queue_time asc (FCFS) Skip jobs not ready Return first ready job Return NULL if all queues exhausted ``` This is an implementation detail of the plugin, not visible to the core loop. ### Preemption flow ``` scheduler controller worker ─────────── ────────── ────── alloc fails for high-prio job J preempt_policy.select_victims(J): collect running jobs from Q_aggressor.preemptible_queues filter: victim.prio < J.prio filter: victim.queue.preemptible rank: lowest priority first select minimum set satisfying J.resreq → [victim1, victim2] XCE_DECISION_PREEMPT ────────────▶ handle_job_preempt() ──────▶ SIGSTP victim1 .victim_jobids = [v1, v2] deliver to each job → JOB_SUSPENDED .signal = SIGSTP victim's worker release resources .preempting_jobid = J J pended → next round → retry alloc ``` ### Core/Driver Architecture ``` ┌──────────────────────────────────────────────────────────────┐ │ DRIVER LAYER │ │ │ │ Normal Mode Simulation Mode │ │ controller capnp ──┐ replay.jsonl ──┐ │ │ handler → events │ virtual clock │ │ │ → publish decisions│ → record/log │ │ │ │ ▲ │ │ ▲ │ │ └───────┼───────┼─────┴──────────────┼───────┼───┴──────────────┘ │ │ decisions │ │ decisions │ │ (callback) │ │ (callback) ▼ │ ▼ │ ┌──────────────────────────────────────────────────────────────┐ │ SCHEDULER CORE │ │ │ │ core_push_event() → picker hooks → picker->next_job() │ │ → try_allocate() → DISPATCH / PREEMPT / PEND │ │ │ │ ZERO dependencies on: controller, capnp, network, wall-clock │ └──────────────────────────────────────────────────────────────┘ ``` ### What stays unchanged - Existing plugin hooks (config, queue, job, worker, state_change, commit, election, prepare_allocation, rusage) — preserved, but the picker no longer uses the dedicated `job_elect_fn` thread - `seqjob_allocator` — wrapped with iterator interface, internally still 1host×1slot - Select expression system — no changes - COMPOUND and ALTER resreq types — unchanged (PRIMARY focus) - Controller communication (handler.c opcodes, decision cbuf in driver) — extended, not replaced - `HOOK_DETAIL_JOB_STOP` — reused for preemption stop notifications ### What's new - `xce_job_picker_iface_t` — pluggable picker interface with full hook specification - `xce_alloc_plan_iter_t` + `xce_host_iter_t` — lazy iterator abstractions - `XCE_DECISION_PREEMPT` decision type + `xce_preempt_decision_t` payload - `builtin_fcfs` — single builtin picker (replaces three separate builtins from original design) - `xce_sched_core_t` / `xce_sched_driver_t` / `xce_sched_sim_driver_t` — core/driver struct split - `xce_core_event_t` — typed event interface between driver and core - `xce_core_decision_cb_t` — decision callback (core → driver) - JSONL replay file format + snapshot/restore - Discrete event-driven virtual clock - `project_name` on queue structs - `job_pick_plugin` on scheduler + queue structs (per-queue override) - `preemptible_queues`, `preemptible`, `preempt_policy` on queue struct ## Scope ### In - Pluggable job picker interface (`xce_job_picker_iface_t` with `next_job()`) hiding all selection logic - Single builtin picker (`builtin_fcfs`) replicating today's behavior - Configurable picker plugin with per-queue override (`queue->job_pick_plugin`) - Picker hook specification: lifecycle, state-change, session, post-allocation, optional hooks - `xce_alloc_plan_iter_t` + `xce_host_iter_t` interfaces (lazy, cancel-safe, composable) - `parjob_allocator` combinatorial plan generation (1host×N, 2hosts×⌈N/2⌉, …, Nhhosts×1) - Preemption: `XCE_DECISION_PREEMPT` decision type, LSF-style cross-queue victim selection, SIGSTP mechanism - Per-queue preemption config: `preemptible_queues`, `preemptible`, `preempt_policy` - Session-level unsatisfiable resreq fingerprint cache - Core/driver separation: passive event-driven core, normal-mode driver, simulation-mode driver - Discrete event-driven virtual clock (monotonic, jumps to next event timestamp) - Runtime recording control via xceadmin commands (`scheduler record start/stop`) - JSONL replay file format (HEADER + SNAPSHOT + events + DECISIONS) - State snapshot/restore for replay efficiency - Deterministic replay guarantee for debugging/tuning - What-if branching (load snapshot, replay alternative delta events) - Data structure changes: `xce_scheduler_t`, `xce_sched_queue_t`, `xce_sched_project_t`, `decision.h`, `xce_sched_core_t`, `xce_sched_driver_t`, `xce_sched_sim_driver_t`, `xce_core_event_t`, `xce_job_picker_iface_t` - Picker plugin interface contract documentation - Unit tests: plan generation, host iteration, cache hit/miss, picker hook behavior, preemption victim selection, event recording/replay, snapshot/restore, deterministic replay - Integration test: multi-slot parallel job allocation and preemption flow end-to-end ### Out - New job selection policies beyond the builtin FCFS picker — the pluggable interface enables them, but none ship here - Hierarchical project resource quotas — deferred to `project-resource-ownership` spec - Resreq template registry — future enhancement - Select expression language changes - Controller-side scheduling state replication (commit barrier wiring) - Compound/ALTER resreq allocation changes - GPU/affinity-aware allocation (pluggable later via allocator plugins) - Kill-and-requeue preemption escalation (v1 is SIGSTP only) - Deadline field on `xce_sched_job_t` - Event payload JSON schema (open design question — deferred to design phase) - Distributed/cluster-mode simulation (v1 is single-scheduler replay only) ## Future: Project Resource Ownership Deferred to a separate `project-resource-ownership` spec. When implemented: - Projects form a tree with resource quota at each node - A project-aware picker plugin selects which project gets resources, then iterates queues scoped to that project, then jobs within each queue - Project quota enforcement: projects over quota are skipped by the picker - The core scheduling loop requires zero code changes — only a new picker plugin replaces `builtin_fcfs` - `queue->project_name` (added here) provides the queue→project mapping the v2 picker consumes - `xce_sched_project_t` scaffolding (added here) provides the struct the v2 picker extends The architecture is designed so that v1 (flat, `builtin_fcfs` ignores projects) and v2 (hierarchical, project-aware picker) share the identical core scheduling loop. ## Boundary Candidates The work naturally splits into these implementation phases (single spec, sequential): 1. **Core/driver separation** — split `xce_scheduler_t` into `xce_sched_core_t` + `xce_sched_driver_t`; define `xce_core_event_t` and `xce_core_decision_cb_t`; implement passive event-driven core interface; wire normal-mode driver to existing handler.c 2. **Picker plugin interface + builtin_fcfs** — define `xce_job_picker_iface_t`; implement `builtin_fcfs` with all MUST/SHOULD hooks; retire dedicated picker thread + `jbuf`; make core loop call `next_job()` synchronously; add `job_pick_plugin` config with per-queue override 3. **Iterator interfaces** — define `xce_alloc_plan_iter_t` and `xce_host_iter_t`, implement for seqjob path first (trivial wrapper), validate the abstraction 4. **Parallel job allocator** — implement combinatorial plan generation in `parjob_allocator`, wire through host iterator 5. **Preemption** — implement `XCE_DECISION_PREEMPT` decision type, cross-queue victim selection policy, SIGSTP pipeline, queue preemption config fields 6. **Unsatisfiable resreq cache** — session-level fingerprint cache, wire into scheduling loop 7. **Simulation driver** — implement simulation-mode driver with JSONL replay, virtual clock, snapshot/restore, deterministic verification 8. **Recording + xceadmin commands** — implement runtime recording control, periodic snapshots during recording 9. **Integration + testing** — end-to-end multi-slot parallel job allocation, preemption flow, picker resolution, cache behavior, simulation replay, HA soak regression ## Out of Boundary - New job selection policy implementations (priority, fair-share, etc.) — these are new plugins that implement `xce_job_picker_iface_t` - Resreq template sharing across jobs (future: `resreq_registry` spec) - Commit barrier wiring for scheduler decisions (separate hardening item in roadmap) - Controller-side changes (the scheduler runs independently, receives updates via existing opcodes) ## Upstream / Downstream ### Upstream (dependencies) - Existing plugin framework (`plugin.h`, `plugin.c`) — NO changes needed - Existing select expression evaluator (`resreq.h` eval/Gc) — NO changes needed - Existing shared resource tracking (`sharedInst`/`sharedInUse`) — NO changes needed - Existing scheduler data structures (`xce_scheduler_t`, `xce_sched_ctx_t`) — split, not replaced ### Downstream (future consumers) - **New job selection policies** — priority, fair-share, deadline, backfill plugins implement `xce_job_picker_iface_t` - **Project-resource-ownership** — project-aware picker plugin consumes `queue->project_name` and `xce_sched_project_t` - **Affinity/GPU allocators** — register as allocator plugins, implement plan iterator for topology-aware placement - **Commit barrier wiring** — scheduling decisions flow through majority commit (separate hardening item) - **Resreq template registry** — named resreqs shared across jobs, reference-counted ## Existing Spec Touchpoints ### Extends - **scheduler-controller-msg-gap** — adjacent (message handling). The framework enhancement operates on the same scheduling state. No conflicts; the msg-gap spec fills handler gaps this spec reads from. ### Adjacent - **ha-consistency-invariants** — commit barrier that scheduling decisions will eventually flow through. This spec does NOT wire the barrier; it only makes allocation decisions that the barrier WILL consume. - **worker-join-refactor** — worker state management. Allocation consumes worker resource availability pushed via the existing join/update path. ## Constraints - **C (gnu17)** — no C++, no external libraries beyond existing (capnp, zlib, pthreads) - **Non-blocking in plugin hooks** — allocation happens on allocator thread, not iomgr. No new blocking concerns. - **Cancel-safe** — `next_job()` must have `pthread_cancel` cancellation points - **Session isolation** — iterator state and resreq cache are per-session, cleared on `begin_session` - **Backward compatible** — existing FCFS + seqjob behavior unchanged. New functionality is additive. - **Deterministic replay** — same initial state + same event sequence → same decisions. No wall-clock time, no unseeded randomness, stable iteration order. - **Memory** — lazy generation avoids materializing all combinations. Worst-case for a job needing N slots on M hosts: M×N candidate evaluations (acceptably bounded).