96 KiB
Requirements Document
Introduction
The XCE scheduler has a plugin architecture but critical foundational gaps. Job selection policy is hardcoded (sched_job_picker() sets job_pick_plugin = "xce_sched_fcfs" as a literal). The scheduler is tightly coupled to controller I/O (apictx, cluster_cfg embedded in xce_scheduler_t). There is no way to swap picking policies without recompilation, and no way to debug or tune scheduling behavior without a live cluster.
This spec establishes one central design invariant and three architectural foundations to achieve it.
Central invariant — Normal↔Sim equivalence: Given identical initial configuration and identical event sequences, the scheduler shall produce identical scheduling decisions regardless of whether it runs in production (normal mode, controller-connected, wall-clock time) or in simulation (replay file, virtual clock). Record a live run → replay it in simulation → the decisions match byte-for-byte. This is NOT a debugging convenience — it is the validation contract for the entire architecture. If normal and simulation diverge, the core/driver separation is broken. This invariant enables every simulation-mode use case: debugging is faithful, tuning is predictive, regression testing is trustworthy, and what-if analysis reflects real behavior.
Three foundations deliver this invariant:
Pluggable job picker: A xce_job_picker_iface_t — the picker thread calls picker->next_job() to get the next schedulable job and hands it to the allocator thread via cbuf, 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. Per-queue picker overrides are supported. The picker runs in a dedicated thread communicating with the allocator via cbuf (existing producer/consumer pattern preserved).
Core/driver separation: The scheduler core is a passive, event-driven engine with zero controller I/O dependencies. A normal-mode driver connects it to a live controller; a simulation-mode driver replays recorded cluster events through a discrete event-driven virtual clock, enabling policy debugging, tuning, regression testing, and what-if analysis without a live cluster.
Core is mode-agnostic: The core has no concept of which mode it runs in. It processes events identically whether the driver is in normal mode or simulation mode. There is no mode flag, no mode-specific code path, and the same init function is used for both modes.
Full discovery context is in
.kiro/specs/scheduler-core/brief.md. Sibling specs:scheduler-allocation,scheduler-preemption.
Queue vs Project — Roles
These are fundamentally different concerns:
| 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 to job | 1 job → 1 queue | 1 job → 1 project |
| Cardinality to each other | Many-to-many | Many-to-many |
- Queue = submission channel with scheduling policy. Owns resource thresholds (RUNLIMIT, MAX_RUNNING), time windows, priority (for inter-queue ordering within the picker), per-queue picker override, and preemption config.
- Project = resource ownership boundary. Owns quota, consumption tracking, hierarchical nesting, and accounting. The picker plugin may use
queue->project_nameinternally for project-scoped iteration (v2).
Boundary Context
- **In scope**: Pluggable job picker interface (`xce_job_picker_iface_t` with `next_job()`) hiding all selection logic behind a single plugin; single builtin picker (`builtin_fcfs`) replicating today's behavior through the new interface; configurable picker plugin with per-queue override (`queue->job_pick_plugin`); **core/driver separation** with passive event-driven core interface, mode-agnostic core (no mode flag, no mode-specific code paths), normal-mode driver (controller-connected), simulation-mode driver as a **standalone investigation tool** (replay recorded events with discrete event-driven virtual clock, zero controller dependencies, self-contained `.replay` file — single capnp binary stream with header + snapshot + events + decisions + periodic checkpoints in one artifact, what-if editing via event file substitution, diff-based comparison of simulated vs recorded decisions via `--sim-output`), virtualized clock access (`xce_core_now()` with two-base + speed formula), runtime recording control via xceadmin commands, `.replay` capnp stream format, state snapshot/restore for replay efficiency, **interactive REPL** with single-step, breakpoints, state inspection, and snapshot capture via `--interactive`, and deterministic replay guarantee for debugging/tuning; **normal↔sim equivalence guarantee** (same config + same events → identical decisions regardless of mode, AC 7.1); `xceadmin scheduler dump <FILE>` for human-readable replay file inspection; documentation of picker plugin interface contract (hooks, cancel-safe, session-aware); unit tests for picker hook behavior, **event recording/replay, snapshot/restore, and deterministic replay**; HA soak regression for picker changes.
-
Out of scope: Allocation iterators and parjob allocator (→
scheduler-allocationspec); preemption policy and SIGSTP pipeline (→scheduler-preemptionspec); new job selection policies beyond the builtin FCFS picker — the pluggable picker interface enables them, but none ship in this spec beyondbuiltin_fcfs; hierarchical project resource quotas (deferred toproject-resource-ownershipspec — onlyproject_nameon queue andxce_sched_project_tscaffolding ship here); decision payload capnp schema forDispatchDecision/PendDecision/PreemptDecision(deferred to design phase — replay file format schema is in AC 20); distributed/cluster-mode simulation (v1 is single-scheduler replay only); visual inspection / UI tool for replay files (deferred —xceadmin scheduler dumpprovides human-readable output,diffof dumps is the primary comparison workflow for v1). -
Adjacent expectations: Depends on the existing plugin framework (
plugin.h/plugin.c), the existing select-expression evaluator (resreq.h, memoized viaselect_eval_ctx_t), and existing shared-resource tracking — none of these change. Sibling specscheduler-allocationprovides thetry_allocate()function this spec's core loop calls. Sibling specscheduler-preemptionprovides thecan_preempt()check and preemption decision type.
Implementation Phases
The spec is implemented in three sequential phases. Each phase ships independently, passes HA soak regression, and preserves existing scheduling behavior. Phases are ordered by dependency — earlier phases produce primitives that later phases consume.
Phase 1: Pluggable Picker (Requirements 1–4)
Scope: Replace the hardcoded "xce_sched_fcfs" literal with a configurable xce_job_picker_iface_t. Implement builtin_fcfs as the default plugin, replicating today's FCFS behavior exactly through the new interface hooks.
Changes to existing code:
- Add
xce_job_picker_iface_twith lifecycle, state-change, session, and post-allocation hooks (Req 1) - Implement
builtin_fcfsplugin with privateordq_tstate, all MUST hooks (Req 3) - Replace
sched_job_picker()hardcoded string withplugin_register/key_findlookup (Req 1 AC 7) - Add
job_pick_plugintoxce_scheduler_tandxce_sched_queue_t(Req 2) - Add
xce_sched_project_tscaffolding +project_nameon queue (Req 2 AC 3)
What does NOT change:
- Today's main loop (
sched_update→sched_begin_session→sched_go→ decision drain →sched_end_session) is untouched - Today's threads (picker, allocator) and cbuf communication are untouched
- Today's controller integration (
scheduler_req_handler, capnp) is untouched - Today's build system, test suite structure is untouched
Verification: HA soak — same job-picking order as today, same decisions, zero divergence.
Why first: Phase 2's core_push_events() must call picker hooks (on_queue_update, on_job_push, on_job_state_change, on_job_update). Implementing the picker first means Phase 2 only moves existing hook call sites — same calls, different location. No dead wiring, no incomplete interfaces.
Phase 2: Core/Driver Separation (Requirement 5, ACs 1–8)
Scope: Extract the scheduling core into a passive, mode-agnostic engine (xce_sched_core_t). Introduce the three-call driver↔core protocol (core_push_events, core_go, on_decision). Build the normal-mode driver that recreates today's controller-connected behavior.
Changes to existing code:
- Extract
xce_sched_core_tfromxce_scheduler_t— all scheduling state, zero controller deps (AC 1) - Implement
core_push_events()— batch-push events, call picker hooks synchronously (AC 2, AC 4) - Implement
core_go()— non-blocking signal to picker thread viacbuf_put(&wbuf)(AC 6) - Implement
on_decision()callback — per-decision emission to driver (AC 6) - Build normal-mode driver: capnp → core events → decisions → xcecli (AC 8)
- Replace today's
main.cdecision switch with driver callback
What does NOT change:
- Picker interface and
builtin_fcfsplugin (Phase 1 already done) - Threading: picker → jbuf → allocator → decisions cbuf pattern preserved
- Controller integration: same capnp messages, same xcecli API, same behavior
- Simulation: not yet wired — core is mode-agnostic but no sim driver exists
Verification: HA soak — same decisions as Phase 1, same controller interaction, zero regression.
Why second: Core extraction needs the picker hooks to exist (Phase 1 done). Core extraction is a prerequisite for Phase 3 — simulation needs a mode-agnostic core that is already tested and passing HA soak in production.
Phase 3: Simulation Mode + Recording + Interactive REPL (Requirement 5, ACs 9–22)
Scope: Add virtual clock (xce_core_now()), snapshot/restore (save_snapshot/load_snapshot), simulation driver (replay file → events → decisions → record file), interactive REPL (step, breakpoints, state inspection, snapshot capture), and runtime recording hooks in the normal-mode driver.
Changes to existing code:
- Add clock virtualization atoms to core:
virtual_clock_base,real_clock_base,speed_num,speed_den(AC 3) - Implement
save_snapshot()/load_snapshot()with picker rebuild from core state (AC 21) - Build simulation driver: load HEADER+SNAPSHOT, replay events, emit decisions to
--sim-output(AC 13, AC 19) - Build interactive REPL: stdin/stdout command loop, breakpoint matching, state inspection queries (AC 22)
- Add recording state to normal driver:
scheduler record start/stop, event writing, periodic snapshots (AC 10–11, AC 12) - Add simulation CLI flags:
--sim,--sim-speed,--sim-verify,--sim-output,--interactive/-i
What does NOT change:
- Core is already mode-agnostic (Phase 2 done) — no
if (simulation)branches added - Picker is already clean (Phase 1 done) — no changes to picker interface
- Normal driver is already split (Phase 2 done) — recording hooks are boolean-gated, zero overhead when off
- Production scheduling behavior is identical — simulation is an opt-in mode
Verification: HA soak (normal mode — no regression). Normal↔sim equivalence (AC 7.1): record a live run, replay in simulation, diff <(xceadmin scheduler dump recorded.replay) <(xceadmin scheduler dump simulated.replay) → decision content identical. Deterministic replay (AC 7): same .replay file twice → same output. Recording: file written correctly, simulation replays recorded file correctly. Interactive REPL (AC 22): --interactive mode steps, breaks, and completes to identical decisions as batch --sim mode. State inspection commands report accurate live state (verified against known-good snapshot dumps).
Why last: Simulation needs Phase 1 (picker hooks exist) and Phase 2 (core is mode-agnostic, driver boundary is clean). It is the only phase that adds net-new capability rather than restructuring existing code — all risk is in Phase 1 and 2. By Phase 3, the new architecture has already been validated in production via HA soak.
Phase Dependency Graph
Phase 1: Pluggable Picker ──► Phase 2: Core/Driver Split ──► Phase 3: Simulation
(Req 1-4) (Req 5 AC 1-8) (Req 5 AC 9-22)
│ │ │
│ No deps │ Needs picker hooks │ Needs mode-agnostic core
│ │ from Phase 1 │ + clean driver boundary
│ │ │ from Phase 2
▼ ▼ ▼
HA soak pass HA soak pass HA soak pass
Each phase: TDD (red → green → refactor) → HA soak regression → merge. No phase gates on unfinished work from a later phase. Each phase ships value independently.
Requirements
Requirement 1: Pluggable job picker interface
Objective: As a maintainer of the scheduler, I want the core scheduling loop to call a single next_job() plugin interface with no knowledge of how jobs are selected, so that picking strategies (FCFS, priority, fair-share, deadline, etc.) are truly pluggable and the core loop never changes regardless of the picking algorithm.
Acceptance Criteria
-
The Scheduler core loop shall call
picker->next_job(picker)to get the next schedulable job (or NULL if none ready), and shall not contain any logic about project iteration, queue iteration, or job ordering — those concerns are entirely encapsulated inside the picker plugin. -
The job picker plugin interface (
xce_job_picker_iface_t) shall define these operations, organized by category:Lifecycle hooks (MUST):
Operation Signature Semantics initint (*init)(void* picker, xce_sched_core_t* core)One-time setup, receives core reference for state queries finivoid (*fini)(void* picker)Teardown and cleanup of all picker-owned resources State-change hooks (MUST — called by core when events arrive from driver):
Operation Signature Semantics Triggered by on_queue_updateint (*on_queue_update)(void* picker, xce_sched_queue_t* queue)Add/update queue in picker's internal ordering QUEUE_UPDATEeventon_job_pushint (*on_job_push)(void* picker, xce_sched_job_t* job)Add new pending job to picker's candidate set JOBS_PUSHeventon_job_state_changeint (*on_job_state_change)(void* picker, xce_sched_job_t* job, uint32_t new_state)Remove job from candidates if no longer pending; re-add if returning to pending JOB_STATE_CHANGEeventon_job_updateint (*on_job_update)(void* picker, xce_sched_job_t* job)Reorder job in candidates if priority/queue changed JOB_UPDATEeventSession hooks (MUST — called at round boundaries):
Operation Signature Semantics begin_sessionint (*begin_session)(void* picker)Reset iteration cursor; called at start of each scheduling round next_jobxce_sched_job_t* (*next_job)(void* picker)Return next schedulable job per picker's ordering, or NULL when exhausted end_sessionint (*end_session)(void* picker)End-of-round notification; called before core sleeps between rounds Post-allocation hooks (MUST — called after allocation attempt):
Operation Signature Semantics on_dispatchint (*on_dispatch)(void* picker, uint64_t jobid)Job was allocated; remove from candidates permanently on_pendint (*on_pend)(void* picker, uint64_t jobid)Job was pended; keep for next round but may deprioritize Optional hooks (MAY — NULL if not needed):
Operation Signature Semantics When needed on_configint (*on_config)(void* picker, xce_conf4sched_t* config)Rebuild picker strategy from config Config-driven pickers (deadline, fair-share weights) on_job_rusageint (*on_job_rusage)(void* picker, xce_sched_job_t* job, xce_job_rusages_t* rusages)Track per-job resource usage Resource-aware pickers (fair-share, backfill) Hooks not defined on the picker interface (worker updates, resource updates, resval updates) are not forwarded to the picker — picking is about job ordering, not resource availability.
-
Threading model: The core uses three threads communicating via cbuf (bounded FIFO with mutex + condition variables,
src/include/mylock.h):Thread Role cbuf In cbuf Out Blocked On Picker Iterates queues/jobs via next_job(), pushes jobidswbuf(from core main)jbuf(to allocator)cbuf_get(&wbuf)between sessionsAllocator Calls try_allocate()/can_preempt(), builds decisionsjbuf(from picker)decisions(to driver)cbuf_get(&jbuf)between jobsCore Main (driver context) Calls core_push_events(), signalscore_go(), drains decisionsdecisions(from allocator)wbuf(to picker)cbuf_get(&decisions)during sessionDriver → Core event delivery: State-change hooks (
on_queue_update,on_job_push,on_job_state_change,on_job_update) are called synchronously duringcore_push_events()in the driver thread, before the call returns. This is safe becausecore_push_events()is only called between sessions when the picker thread is idle (blocked oncbuf_get(&wbuf)) and the allocator thread is idle (blocked oncbuf_get(&jbuf)). No mid-session event injection.Session hooks (
begin_session,next_job,end_session) run in the picker thread. Post-allocation hooks (on_dispatch,on_pend) run in the allocator thread. -
The core scheduling loop shall use a producer/consumer pattern across three threads communicating via cbuf:
Picker thread (
fcfs_elect_jobequivalent):while (1) { int go = 0; cbuf_get(&core->wbuf, &go); // block until core_go() signals picker->begin_session(picker); xce_sched_job_t* job; while ((job = picker->next_job(picker)) != NULL) { cbuf_put(&core->jbuf, &job->jobid); // hand off to allocator } uint64_t end_marker = 0; cbuf_put(&core->jbuf, &end_marker); // end-of-session signal picker->end_session(picker); }Allocator thread:
while (1) { uint64_t jobid = 0; cbuf_get(&core->jbuf, &jobid); // block until picker sends job if (jobid == 0) { // end-of-session marker cbuf_put(&core->decisions, NULL); continue; } xce_sched_job_t* job = sched_get_job(core, jobid); int rc = try_allocate(job); if (rc > 0) { picker->on_dispatch(picker, job->jobid); decision = make_dispatch_decision(job->allocs); } else if (can_preempt(job)) { decision = make_preempt_decision(job, victims); picker->on_pend(picker, job->jobid); } else { decision = make_pend_decision(job->reason); picker->on_pend(picker, job->jobid); } cbuf_put(&core->decisions, &decision); }Main loop (driver thread context —
core.go()):atomic_store(&core->session_active, 1, memory_order_release); core_begin_session_internal(core); // select eval ctx, reset workers cbuf_put(&core->wbuf, &go); // NON-BLOCKING: signal picker, return // Driver then drains decisions cbuf: while (1) { cbuf_get(&core->decisions, &decision); if (decision == NULL) break; // session exhausted core->on_decision(core->driver_ctx, decision); // publish immediately } core_end_session_internal(core); // GC, reset workers atomic_store(&core->session_active, 0, memory_order_release);The picker and allocator contain zero knowledge of project/queue/job ordering — all selection logic lives behind
next_job(). The allocator contains zero knowledge of how the driver publishes decisions — it only callscbuf_put(&decisions, &decision). -
The picker plugin shall be cancel-safe:
next_job()shall have definedpthread_cancelcancellation points. The picker shall be session-aware:begin_session()resets iteration state,end_session()is called before sleep-between-rounds. -
The hardcoded
"xce_sched_fcfs"literal atscheduler.c:43(insched_job_picker()) shall be replaced by loading the configuredsched->job_pick_pluginvia the existingplugin_register/key_findmechanism. If the configured plugin name is not registered, the Scheduler shall fail with a clear configuration error. -
The existing multi-threaded producer/consumer architecture (picker thread →
jbufcbuf → allocator thread →decisionscbuf → main loop) shall be preserved. The driver thread signals the picker thread viawbufcbuf (non-blockingcbuf_put). The only thread eliminated is the conceptual one: today's FCFS picker is a dedicated thread, and it stays a dedicated thread — but the core scheduling loop in the driver is simplified because decision publishing moves from a hardcodedmain.cswitch to a driver-registeredon_decisioncallback.
Requirement 2: Configurable picker plugin
Objective: As a cluster operator, I want to configure which job-picking policy the scheduler uses without recompiling, and override it per-queue, so that FCFS, priority, or custom pickers can be swapped operationally.
Acceptance Criteria
-
The Scheduler shall add a
job_pick_pluginfield toxce_scheduler_t, defaulting to"builtin_fcfs"when unset — this is the global picker plugin name. -
The Scheduler shall add a
job_pick_pluginfield toxce_sched_queue_t, defaulting to NULL (inherit from global). When non-NULL, the configured global picker plugin shall use this per-queue override for jobs within that queue. How the picker plugin uses per-queue overrides is an implementation detail of the plugin, not the core loop. -
The Scheduler shall add a
project_namefield toxce_sched_queue_t, set from queue configuration, establishing the queue→project relationship. The picker plugin may use this field internally for project-scoped iteration. -
The Scheduler shall accept the picker plugin name from scheduler configuration (or equivalent operator-facing settings), applied at scheduler startup without requiring a rebuild.
-
When the configured plugin name is not registered via the existing
plugin_register/key_findmechanism, the Scheduler shall fail with a clear configuration error rather than silently falling back to the builtin default. -
The picker plugin interface contract (
xce_job_picker_iface_t) shall be documented: each picker must be cancel-safe (responds topthread_cancelat defined points innext_job()) and session-aware (resets its iteration state onbegin_session()).
Requirement 3: Builtin default picker
Objective: As a maintainer, I want the new architecture to ship with a builtin picker plugin that replicates today's exact scheduling behavior, so that operators who don't configure a custom picker see no behavioral change.
Acceptance Criteria
-
Registration:
builtin_fcfsshall be registered automatically at scheduler startup as"builtin_fcfs"and set as the defaultsched->job_pick_plugin. -
Hook implementation —
builtin_fcfsshall implement every MUST and SHOULD hook fromxce_job_picker_iface_t(Req 1 AC 2) as follows:Hook FCFS Implementation initAllocate fcfs_plugin_data_t. Initializeordq_t queues(ordered byqueue->priority desc). Set iteration cursor to NULL.finiFree all per-queue ordq_t pendJobs, theordq_t queues, and plugin data.on_queue_updateUpsert queue into ordq_t queues. If queue exists, update its priority and reorder. If new, allocatefcfs_queue_twith emptyordq_t pendJobs.on_job_pushIf job->state & JOB_STAT_PEND: find the job's queue inordq_t queues, addfcfs_job_t{job}to that queue'sordq_t pendJobs(ordered bypriority desc, queue_time asc).on_job_state_changeIf new_stateis not pending (EXIT/FAIL/DONE): remove job from its queue'spendJobsby jobid. Ifnew_statereturns to PEND: re-add topendJobs.on_job_updateIf job's priorityorqueuefield changed: remove from old position inpendJobs, re-insert at correct position per ordering.begin_sessionReset iteration cursor to head of ordq_t queues.next_jobWalk ordq_t queuesfrom cursor. For each queue, skip ifsched_queue_is_ready()returns false. Within queue, walkordq_t pendJobs. Return first job wheresched_job_is_ready()returns true. Advance cursor. Return NULL when all queues and jobs exhausted.end_sessionNo-op for FCFS (iteration state reset in begin_session).on_dispatchRemove job from its queue's ordq_t pendJobsby jobid.on_pendNo-op for FCFS — job remains in pendJobs, will be retried next round in same position.on_configNULL — FCFS is config-agnostic. on_job_rusageNULL — FCFS is not resource-aware. -
Internal data structures —
builtin_fcfsshall maintain its own private state, not query the core'ssched->queues/sched->jobsduringnext_job(). All candidate management happens in the state-change hooks. This ensures:next_job()is fast — no core lock acquisition, no hash table scans- The picker is independently testable — feed hooks, verify
next_job()order - Simulation snapshots capture picker state naturally via the hooks already recorded in the event stream
-
Ordering equivalence —
builtin_fcfsshall produce the same job-picking order as today'sfcfs_elect_job: inter-queue by descendingqueue->priority, intra-queue bypriority desc, queue_time asc. Behavior with per-queue picker overrides is as specified in Req 2 AC 2. -
Dedicated picker thread retained —
builtin_fcfsshall implementnext_job()as a synchronous call within the picker's dedicated thread. The picker thread communicates with the allocator thread viajbufcbuf (existing pattern).pthread_cancelcancellation points shall exist in thenext_job()iteration loop and incbuf_get(&wbuf)where the picker blocks between sessions. -
Session state —
end_session()is a no-op. Iteration state is reset inbegin_session(). Undispatched jobs remain inpendJobsacross sessions (they carry over to the next round).
Requirement 4: No regression to existing picker behavior or HA soak
Objective: As a maintainer, I want the picker rework and core/driver separation scoped so that existing FCFS behavior is unaffected, making the framework change safe to ship independently of any new policy.
Acceptance Criteria
builtin_fcfsshall produce the same job-picking order as today'sfcfs_elect_job, given the same queue and job state — same inter-queue priority ordering and same intra-queue (priority desc, queue_time asc) ordering.- Existing scheduler unit tests (
schedulermeson test suite) shall continue to pass unmodified in assertions, adjusted only where they directly exercised the removed hardcoded-string and the refactoredsched_go()/ decision publishing path this feature replaces. The picker thread, allocator thread, and cbuf communication pattern are preserved. - A full HA soak (sequential and chaotic
/xce-test-haregression) shall show no new job loss, divergence, or liveness stall attributable to this feature, confirming the scheduler-side change does not perturb controller-side commit/replication behavior it reads state from.
Requirement 5: Core/Driver Separation with Dual-Mode Operation
Objective: As a scheduler developer, I want the scheduling core separated from all external I/O so that the same core logic runs deterministically in production (driven by a live controller) and in simulation (driven by recorded cluster events with a discrete event-driven virtual clock), enabling policy debugging, tuning, regression testing, and what-if analysis without a live cluster.
Acceptance Criteria
Driver ↔ Core Protocol (overview)
The driver and core communicate through a three-call synchronous interface with cbuf-based internal threading. The driver buffers controller events externally between sessions, pushes them as a batch before each scheduling round, and receives decisions one-by-one as they are produced.
| Direction | Call | Semantics | Thread |
|---|---|---|---|
| Driver → Core | core_push_events(core, events, n) |
Batch-push events before session. State-change hooks called synchronously. Only valid when picker is idle (between sessions). | Driver thread |
| Driver → Core | core_go(core) |
Start scheduling session. NON-BLOCKING — signals picker thread via cbuf, returns immediately. Returns -EINVAL if session already active. |
Driver thread |
| Core → Driver | on_decision(ctx, decision) |
Each scheduling decision emitted immediately as produced. Called from allocator thread context. | Allocator thread |
| (implicit) | picker sends jobid=0 through jbuf |
End-of-session marker. Allocator forwards as NULL through decisions cbuf. |
Picker → Allocator → Driver |
Event buffering discipline: The driver accumulates controller events (capnp push/notify messages from the handler thread) into pend_updates (existing dataq swap mechanism). Between sessions, the driver drains pend_updates into xce_core_event_t array and calls core_push_events(). No events are injected mid-session — the picker thread is blocked on cbuf_get(&wbuf) and the allocator thread on cbuf_get(&jbuf), so core_push_events() has exclusive access to picker state.
Decision publishing: Each decision is published immediately to the controller (not batched at session end). The driver's on_decision callback calls xcecli_jobdispatch() / xcecli_set_pend_reason() / xcecli_preempt() synchronously. This matches today's main.c:181-196 behavior.
Mode selection: Mode is chosen at startup via CLI flags. The core has zero knowledge of which mode is active:
// main.c — mode selection (single branch)
if (arguments.sim_file) {
sched_sim_driver_run(&sim_driver, arguments);
} else {
sched_driver_run(&driver, &config, cluster_cfg, handler);
}
| Flag | Mode | Purpose |
|---|---|---|
| (none) | Normal | Controller-connected (current behavior) |
--sim FILE |
Simulation | Replay .replay file |
--sim-speed N |
Simulation | 0=fast, 1=real-time, N=N× |
--sim-start-ts NS |
Simulation | Start replay from logical time |
--sim-verify |
Simulation | Compare decisions against recorded |
--interactive / -i |
Simulation | Open REPL for step-through debugging |
--sim-alt-events FILE |
Simulation | What-if branching |
--sim-output FILE |
Simulation | Write simulated decisions to .replay file |
Migration from old job_elect_fn: The existing xce_sched_plugin_t->job_elect_fn (which fcfs_elect_job implements) is superseded by xce_job_picker_iface_t->next_job(). The old job_elect_fn shall be deprecated with a warning at plugin register time. The new builtin_fcfs picker implements the new interface exclusively. Existing plugins that only implement job_elect_fn continue to work via a compatibility wrapper during the transition period, but new pickers shall implement xce_job_picker_iface_t.
Core Interface Contract
-
Mode-agnostic core: The Scheduler core shall have no concept of which mode it runs in. It processes events identically whether the driver is in normal mode (controller-connected, wall-clock time) or simulation mode (replay file, virtual clock). Specifically:
- There shall be no mode flag (
bool simulation_modeor equivalent) anywhere in the core - There shall be no
if (simulation) { ... } else { ... }code path in the core xce_sched_core_init()shall take identical parameters regardless of mode- The core's decision callback shall be the same function pointer type in both modes
- The core's
save_snapshot()andload_snapshot()shall be identical in both modes
- There shall be no mode flag (
-
Passive event-driven interface: The core shall expose a pure event-driven interface — it receives typed events from the driver, updates internal scheduling state, and emits decisions through a driver-registered callback. The core shall have zero direct dependency on: controller communication, capnp serialization, network I/O, filesystem access, or wall-clock time sources.
-
Virtualized clock access: The core shall access time exclusively through
xce_core_now(). The core shall never calltime(NULL),clock_gettime(),gettimeofday(), or any other wall-clock time function directly.Clock virtualization mechanism — two-base + speed formula:
The core stores four fields (atomically updated by the driver as a group):
Field Type Description virtual_clock_base_Atomic uint64_tVirtual-time anchor (nanoseconds) real_clock_base_Atomic uint64_tWall-clock anchor captured when virtual_clock_basewas set (nanoseconds,CLOCK_MONOTONIC)speed_num/speed_den_Atomic uint64_tSpeed multiplier as rational fraction xce_core_now(core)computes:uint64_t wall_ns = clock_gettime(CLOCK_MONOTONIC); // only wall-clock call in the core uint64_t elapsed = wall_ns - core->real_clock_base; return core->virtual_clock_base + (elapsed * core->speed_num) / core->speed_den;Mode virtual_clock_basereal_clock_basespeedBehavior Normal 0 0 1/1 xce_core_now()= wall clock (degraded:0 + (wall - 0) × 1)Simulation discrete event tswall at load 0/1 xce_core_now()= frozen at eventts(time does not advance between events)Simulation real-time snapshot tswall at load 1/1 xce_core_now()tracks wall clock from virtual anchorSimulation fast/slow snapshot tswall at load N/1 or 1/N xce_core_now()runs N× faster or slower than wall clockThe driver atomically updates all four fields before each event dispatch. Multi-thread safety:
xce_core_now()reads atomic fields lock-free. Both picker and allocator threads independently compute the same virtual time from the shared bases — no mutex needed.All internal time uses go through
xce_core_now(): scheduling logic, logging (via tlog format function usinguserptr), error messages, debug traces. Jobsub_timeandqueue_timeare set by the driver before pushingJOBS_PUSH.Third-party library constraint: any library linked into the core that needs time must obtain it through
xce_core_now()or a core-provided wrapper. Specifically:- Logging (tlog): The core shall register its own format function via
tlog_reg_format_func(fn, core_ptr)wherecore_ptris thexce_sched_core_t*. This format function shall ignoreinfo->timeand instead readxce_core_now(core_ptr), converting nanoseconds tostruct tmfields viagmtime_r()(deterministic — pure computation). - libxce subset: The core shall link only against the minimal data-structure subset of libxce (
ordq,dataq,cbuf,xlist,zarray,kvmgr,rtti) — not againstnetio,delay,xceapi, oridgenwhich call wall-clock time internally. - pthread timed-blocking: The core is multi-threaded. In simulation mode, thread interleaving is made deterministic —
pthread_cond_timedwait()andpthread_mutex_timedlock()are not needed in the deterministic execution path. - Libraries that cannot avoid calling wall-clock time directly must not be linked into the core; such concerns stay in the driver layer.
- Logging (tlog): The core shall register its own format function via
-
Core event types — detailed payload specification: The core interface shall accept at minimum these input events. Each event carries a timestamp (
ts, nanoseconds since epoch) and a type-specific payload. The field set below is normative for implementation; the exact capnp message schema is deferred to the design phase.CONFIG_PUSH— Full configuration push (scheduler startup or config reload):Field Type Description configxce_conf4sched_t*All queues, resource thresholds, preemption config, time windows, global policy settings → Picker hook: on_config(picker, config)JOBS_PUSH— Newly submitted jobs:Field Type Description njobsintNumber of jobs in this batch jobsxce_job_t*[njobs]Job records: jobid, user, project, queue, priority, state, queue_time, resreq → Picker hook: on_job_push(picker, job)for each jobWORKERS_PUSH— Worker list update (nodes added/removed):Field Type Description nworkersintNumber of workers in this update workersxce_worker_t*[nworkers]Worker records: workerid, host, resources, state, assigned_jobs RESOURCES_PUSH— Resource definitions (new resource types registered):Field Type Description nintNumber of resource definitions resourcesxce_resource_t*[n]Resource records: name, type, total, flags RESVALS_FULL— Full resource value snapshot (periodic or after large change):Field Type Description nintNumber of resource value entries resvalsxce_resval_t*[n]Each: workerid, resource_name, available, used, reserved RESVALS_DELTA— Incremental resource value update (fine-grained changes):Field Type Description deltasxce_resval_list_t*Linked list of individual resval changes (add/subtract) JOB_STATE_CHANGE— Job started, exited, or failed:Field Type Description jobiduint64_tJob identifier old_stateuint32_tPrevious job state (for context) new_stateuint32_tNew job state: RUNNING, EXIT, FAIL, DONE, PEND exit_codeint32_tExit code (meaningful only for EXIT/FAIL) → Picker hook: on_job_state_change(picker, job, new_state)JOB_UPDATE— In-place job field modification (priority change, queue reassignment):Field Type Description jobiduint64_tJob identifier changed_fieldsuint32_tBitmask of fields that changed (JOB_F_PRIORITY, JOB_F_QUEUE, JOB_F_RESREQ) jobxce_job_t*Full job record with updated fields → Picker hook: on_job_update(picker, job)QUEUE_UPDATE— Queue definition added or modified:Field Type Description queuexce_queue_t*Queue record: name, priority, RUNLIMIT, MAX_RUNNING, state, time_window, job_pick_plugin, project_name → Picker hook: on_queue_update(picker, queue)JOB_RUSAGE— Job resource usage update (for resource-aware pickers):Field Type Description jobiduint64_tJob identifier rusagesxce_job_rusages_t*Resource usage metrics: cpu_time, mem_bytes, io_bytes → Picker hook: on_job_rusage(picker, job, rusages)(optional — NULL if not registered)BEGIN_ROUND— Trigger a scheduling round (session boundary, see AC 18):Field Type Description timestamp_nsuint64_tLogical time at which the round should execute END_SESSION— Explicit end-of-recording marker (written to replay file, never sent to core):Field Type Description timestamp_nsuint64_tLogical time when recording stopped -
Decision output types: The core shall emit decisions through the
on_decisioncallback. Three decision types exist:Decision Type Fields Driver Action DISPATCHjobid,workerid,allocations[](resource, amount per allocation)xcecli_jobdispatch()PENDjobid,reason(code + message)xcecli_set_pend_reason()PREEMPTjobid,victims[](jobid, workerid per victim),reasonxcecli_preempt()These are recorded as
ReplayDecisionmessages withinReplaySession.decisions. The exact decision payload schema is deferred to the design phase. -
Decision output callback: The core shall emit all scheduling decisions (
DISPATCH,PEND,PREEMPT) through axce_core_decision_cb_tcallback registered by the driver at init time. Signature:int (*on_decision)(void* driver_ctx, xce_decision_t* decision). This replaces the current internaldecisionscbuf poll loop inmain.c. -
Deterministic guarantee: Given identical initial state and identical event sequences with identical timestamps, the core shall produce identical decision sequences. The core is multi-threaded (picker + allocator threads communicating via cbuf). Deterministic replay is achieved through:
- cbuf capacity=1 serialization: The
jbufcbuf between picker and allocator has capacity 1 (cbuf_init(&jbuf, 1, sizeof(uint64_t))). This acts as a rendezvous — the picker blocks oncbuf_putuntil the allocator takes the current jobid. Jobs flow through the pipeline one at a time in fully determined order. - Stabilized hash table iteration: All hash table / key-value store iteration that affects scheduling output order shall be stabilized (sorted by key before iterating).
- PRNG for tie-breaking: Any required randomness (e.g., tie-breaking) shall use a PRNG seeded by the driver at init time (
core->prng_seed). - Same events → same picker state: Since
core_push_events()is called between sessions (picker idle), the picker's internal data structures (ordq_t queues,ordq_t pendJobs) are populated identically given identical event sequences. The picker iteration then produces jobids in identical order.
7.1 Normal-mode ↔ simulation equivalence: Given identical initial configuration (
xce_conf4sched_t) and identical event sequences (same job submissions, state changes, resource updates at the same logical positions), the core shall produce identical decision sequences in both normal mode and simulation mode. Specifically:- Record a normal-mode scheduling session (AC 10) → produce
.replayfile R - Run simulation on R (AC 13) → produce
.replayfile S diff <(xceadmin scheduler dump R) <(xceadmin scheduler dump S)→ only non-decision metadata may differ (timestamps inReplayHeader, wall-clock annotations inSessionSummary). EveryReplayDecisionshall match identically: same jobid, same workerid, same allocation amounts, same pend reasons.
This constraint is the validation contract for the entire core/driver architecture. If normal mode and simulation mode diverge, one of three things is broken:
- The core has a hidden dependency on wall-clock time or controller I/O (violates AC 1)
- The event recording is lossy — dropping events that matter to scheduling decisions (violates AC 10)
- The simulation driver's input translation from capnp or
.replayformat is not faithfully reproducing the normal-mode event stream
This constraint turns recording + replay from a debugging convenience into a regression test: after any scheduler change, record a live run, replay it in simulation, and verify the decisions are byte-for-byte identical. Any divergence is a bug.
- cbuf capacity=1 serialization: The
-
Driver event queue: The driver shall retain an internal pending-event queue to buffer events received from the controller between scheduling rounds. Events accumulate in the driver between
BEGIN_ROUNDinvocations.
Normal-Mode Driver
-
Controller integration: The normal-mode driver shall receive capnp push/notify messages from the controller, translate each to the corresponding core event type, and invoke the core's event push interface. The driver shall publish core-emitted decisions back to the controller via the existing
xcecli_ctx_tAPI. -
Runtime recording control — detailed mechanism: The scheduler shall support runtime recording of all scheduling events and decisions to a replay file for later simulation and analysis.
Admin command flow: xceadmin CLI connects directly to the controller via TLS + capnp (same as
xceadmin config push— no apiserver, no REST). The controller receivesOPCode_schedAdminand forwards it to the scheduler via the existingscheduler_t.ependpoint. The scheduler processes the request and replies; the controller relays the reply back to xceadmin. This is a request-reply opcode, NOT a one-way notify — recordingstatusreturns live scheduler state that the controller does not own.Why request-reply (not notify): The existing
OPCode_notifyis fire-and-forget — no reply path. Recordingstart/stopcould theoretically use notify, butstatusmust return live state (recording,file,event_count,started_at). Using two different mechanisms for three commands on the same subsystem is worse than one consistent request-reply path. Additionally,start/stopbenefit from a synchronous reply:startreturns the auto-generated filename, andstopreturns the final event count.Why a dedicated opcode (not reusing OP_upgradeConfig):
OP_upgradeConfigcarries a config blob through the controller's own config-replication pipeline. Recording control is a scheduler-scoped admin action — it touches no controller state, needs no WAL persistence, and should be forwarded directly to the scheduler without controller-side interpretation. A dedicatedOPCode_schedAdminkeeps these concerns separate.Cap'n Proto schema additions (in
controller.capnp):New OPCode:
enum OPCode { ... schedAdmin @42; }New types:
enum SchedAdminAction { recordStart @0; recordStop @1; recordStatus @2; } struct SchedAdminRequest $C.mapname("sched_admin_req_t") { action @0: SchedAdminAction; recordFile @1: Text; # path for recordStart; empty = auto-generate } struct SchedAdminReply $C.mapname("sched_admin_reply_t") { ok @0: Bool; error @1: Text; # human-readable error if !ok # Fields populated for recordStatus (also returned by recordStart/recordStop # for confirmation — caller can verify state after each action): recording @2: Bool; # true between start and stop file @3: Text; # record file path (auto-generated name returned by start) eventCount @4: UInt64; # events written since recording started startedAt @5: UInt64; # logical time recording started (nanoseconds since epoch) }New Request.body union entry:
schedadminmsg @31: SchedAdminRequest;New RequestReply.data union entry:
schedadminreplymsg @19: SchedAdminReply;Controller forwarding (in
myreq_handler):case OPCode_schedAdmin: → validate xceadmin auth (admin RBAC, same as config push) → lock(&c->sched.lock) → if sched.ep == NULL: unlock; reply {ok=false, error="scheduler not connected"} → forward to sched.ep via existing capnp push/request → await scheduler reply (timeout: 5s) → unlock → relay SchedAdminReply back to xceadmin via RequestReplyThe controller is a pass-through — it owns no recording state, interprets no fields beyond the opcode dispatch. All recording logic lives in the scheduler's normal-mode driver. In the new architecture, recording is a driver-layer concern — the core has zero knowledge of whether recording is active.
Edge cases:
- Scheduler not connected (
sched.ep == NULL): Controller replies{ok=false, error="scheduler not connected"}immediately. No capnp round-trip needed. - Scheduler disconnects mid-request: The 5s timeout triggers; controller replies
{ok=false, error="request timeout"}. - Controller is not primary: xceadmin's existing redirect-following logic handles this — if the contacted controller is not the primary, it redirects to the primary (same as
config push). The scheduler is a singleton attached to the primary controller, so recording commands always reach the primary's scheduler.
xceadmin CLI surface:
xceadmin scheduler record start [FILE] → SchedAdminRequest{action=recordStart, recordFile=FILE or ""} → Success: {ok=true, file="<auto or specified>", recording=true, eventCount=0, startedAt=<logical_time>} → Error: {ok=false, error="already recording", recording=true, file="<current>"} xceadmin scheduler record stop → SchedAdminRequest{action=recordStop} → Success: {ok=true, recording=false, eventCount=14273, file="<was>", startedAt=<original_start>} → Error: {ok=false, error="not recording", recording=false} xceadmin scheduler record status → SchedAdminRequest{action=recordStatus} → Success: {ok=true, recording=true|false, file="<current or empty>", eventCount=N, startedAt=<original_start or 0>}Reply field semantics by action:
Field recordStart (ok) recordStart (err) recordStop (ok) recordStop (err) recordStatus oktrue false true false true error"" reason (e.g. "already recording") "" reason (e.g. "not recording") "" recordingtrue true (unchanged) false false (unchanged) true/false filecurrent path current path last path (informational) "" current or "" eventCount0 current count final count 0 current startedAtstart time original start original start (historical) 0 original or 0 Error codes reference:
Condition okerrorrecord startwhile already recordingfalse "already recording"record stopwhile not recordingfalse "not recording"Scheduler not connected to this controller false "scheduler not connected"Request timeout (5s) false "request timeout"Disk full / write error (start) false "cannot write to <file>: <errno>"Status output (xceadmin formats the capnp reply):
recording: yes file: /var/log/xce/scheduler-replay-prod-cluster-1-20260729-020900.replay events: 14273 started at: 2026-07-29T02:09:00Permissions: All three commands require admin-level authentication (same RBAC role as
config push). The controller validates the xceadmin auth before forwarding to the scheduler — the scheduler trusts the controller's forwarding (same trust model as existing notify events from the controller).Recording state (in
xce_sched_driver_t):bool recording; // true between start and stop char* record_file_path; // strdup'd path for status queries FILE* record_file; // replay file handle (NULL when not recording) uint64_t record_event_count; // counter for periodic snapshot uint64_t record_snapshot_interval; // N events between snapshots (0 = disabled) uint64_t record_start_ts; // logical time when recording started (0 = not started)record_file_pathis astrdup'd copy of the auto-generated or user-specified path, owned by the record file lifecycle:strdupon start,free+NULL on stop or error. This field exists soSchedAdminReply.filecan be populated even afterrecord_fileis closed — it persists for informational queries (e.g.,statusafter a disk-full auto-stop).Recording lifecycle — what gets written at each step:
-
On
record start:- Take full core snapshot via
save_snapshot(core, &snapshot_data)— fillsSnapshotDatastruct in memory - Write
ReplayHeaderas first message of the file (recorder version, config hash, cluster identity, timestamps, sched policy) - Serialize
SnapshotEntry{ts, data=snapshot_data}and write as second message - Set
recording = true,record_event_count = 0
- Take full core snapshot via
-
On each
core_push_events()call (driver thread, between sessions):- For each event in the batch, serialize and write the corresponding
ReplayEventmessage to the.replayfile - Increment
record_event_countfor each event written
- For each event in the batch, serialize and write the corresponding
-
On
core_go()(driver thread, start of session):- Begin building a
ReplaySessionmessage withbeginRound = xce_core_now(core)
- Begin building a
-
In the decision drain loop (driver thread, during session):
- For each decision from
cbuf_get(&decisions): append aReplayDecisionto the currentReplaySession.decisionslist - Decisions are in allocator output order, before the NULL end-of-session marker
- For each decision from
-
On
core_end_session()(driver thread):- Finalize the
ReplaySessionwith session summary, write the complete message to the.replayfile
- Finalize the
-
On periodic checkpoint (driver thread):
- If
record_event_count > 0 && record_event_count % record_snapshot_interval == 0: - Serialize and write
SnapshotEntrymessage fromsave_snapshot(core, &snapshot_data)
- If
-
On
record stop:- Write
ReplayEvent{endSession}message fflush()+fclose()the record file- Set
recording = false,record_file = NULL
- Write
Recording insert points in the normal-mode driver main loop (annotated):
while (!sched.stop) { sched_update(&sched); // process capnp push/notify // [RECORDING] write each buffered event as ReplayEvent message // [RECORDING] periodic checkpoint check sched_begin_session(&sched); // [RECORDING] build ReplaySession{beginRound = now} sched_go(&sched); while (cbuf_get(&sched.decisions, &d), d) { // [RECORDING] append ReplayDecision to session switch (d->op) { case DISPATCH: sched_publish_dispatch(...); break; case PEND: sched_publish_pend_reason(...); break; case PREEMPT: sched_publish_preempt(...); break; } } sched_end_session(&sched); // [RECORDING] finalize + write ReplaySession message // [RECORDING] periodic checkpoint after session sleep(1); }Performance: When recording is inactive (
recording == false), the scheduler shall incur zero overhead — no file I/O, no extra allocations, no event buffering beyond what the non-recording path already does. The recording check is a single boolean branch per session.Error handling: If writing to the record file fails (disk full, permission error), the driver shall log the error, set
recording = false,fflush()+fclose()the record file,free(record_file_path)+ setrecord_file_path = NULL, and continue normal scheduling operation. Recording failure shall not cause the scheduler to stop or crash. - Scheduler not connected (
-
Periodic snapshot during recording: While recording is active, the driver shall periodically append fresh snapshots to the replay file (default: every
Nevents, configurable via--record-snapshot-interval N, N=0 disables periodic snapshots). -
Session response time metrics: The core shall track session duration for diagnostics. At
core_go(), the core storessession_start = xce_core_now(). Atcore_end_session(), the core computessession_duration_ns = xce_core_now() - session_start. The duration is written to theReplaySession.summaryfield as aSessionSummarystruct. TheSessionSummaryis optional for replay correctness (the simulation driver ignores it) but provides valuable diagnostics:- Normal mode: reveals real scheduling latency — is the picker slow? is allocation the bottleneck?
- Simulation mode: in real-time mode, compare virtual vs wall-clock session duration; in fast mode, the virtual clock freezes during
core_go()soduration_nsis typically 0 (all decisions at samets) - Tuning: diff session durations before and after a policy change to quantify performance impact
The core stores
session_startinternally — no new API functions, no change to theon_decisioncallback, no overhead beyond a singlexce_core_now()call per session.
Simulation-Mode Driver
-
Simulation mode startup: The scheduler shall support simulation mode activated by
--sim FILEand--sim-start-ts TIMESTAMP. The startup sequence is:Step 1 — Open and validate replay file:
sim->replay_file = fopen(arguments.sim_file, "r"); read first capnp message → validate it is ReplayHeaderIf the file is missing, unreadable, or the first message is not a valid
ReplayHeader, the simulation driver shall fail with a clear error message.Step 2 — Locate starting snapshot:
scan forward from ReplayHeader, decode each SnapshotEntry find the most recent SnapshotEntry with ts <= --sim-start-tsIf no
SnapshotEntryexists afterReplayHeader, the simulation driver shall reject the file. Without initial state, simulation has no starting point.Step 3 — Load snapshot and rebuild picker:
xce_sched_core_load_snapshot(&sim->core, snapshot_entry.data) // Walk core state to rebuild picker private state: for each queue in core.queues: picker->on_queue_update(picker, queue) for each job in core.jobs where job.state & JOB_STAT_PEND: picker->on_job_push(picker, job)After this step, core state and picker state are fully restored. The core is ready to run.
Step 4 — Set virtual clock:
sim->core.virtual_clock_base = snapshot_ts sim->core.real_clock_base = clock_gettime(CLOCK_MONOTONIC) // wall anchor sim->core.speed_num = arguments.sim_speed // 0=fast, 1=real-time, N>1=N× sim->core.speed_den = 1From this point,
xce_core_now()returns virtual time computed from the snapshot's logical timestamp.Step 5 — Enter event loop (AC 18): accumulate events → BEGIN_ROUND →
core_push_events→core_go→ drain decisions →core_end_session→ repeat until EOF.The
ReplayHeader+SnapshotEntrytogether form the complete self-contained starting point. Nothing outside the replay file is needed to begin simulation. -
Discrete event-driven virtual clock: The simulation driver shall implement a virtual clock that advances by jumping directly to the timestamp of the next queued event — no fixed tick interval, no wall-clock waiting. Strictly monotonically increasing. Same-timestamp events delivered in file order. Supports:
--sim-speed N— N=0 as-fast-as-possible, N=1 real-time, N>1 N× real-time--interactive/-i— open REPL for step-through debugging, breakpoints, state inspection, and snapshot capture (AC 22)
-
Deterministic replay verification: In simulation mode with
--sim-verify, the driver shall compare each core-emitted decision against the recorded decisions in the replay file. Any mismatch shall be reported as a regression with exact logical-time and decision-detail context. -
What-if branching: The simulation driver shall accept
--sim-alt-events FILE. When both a base.replayfile (for snapshot loading) and an alt-events file are provided, the driver loads the snapshot from the base file, then replays events from the alt-events file (same.replaycapnp stream format) — enabling "what-if" analysis. -
Simulation isolation: In simulation mode, the scheduler process shall not establish any connection to the controller. The
xce_sched_sim_driver_tshall contain noxcecli_ctx_t, noxce_cluster_config_t, no capnp socket, and no RPC to any controller. All scheduling input comes exclusively from the replay file. All scheduling decisions go to the simulation record file (see AC 19) and/or stdout. Attempting to use a controller-facing API from simulation mode is a programming error that shall be caught at compile time (via struct field absence) or fail with a clear error at startup. -
BEGIN_ROUND as session boundary: The simulation driver shall treat each
ReplaySessionmessage as the trigger for one complete scheduling session. The driver accumulatesReplayEventmessages into a batch, and upon encounteringReplaySession, executes the full session cycle:// Simulation driver event loop xce_core_event_t buf_events[MAX_EVENTS_PER_ROUND]; int buf_n = 0; while (decode_next_message(replay_file, &msg) == 0) { switch (msg.which) { case ReplayEvent: buf_events[buf_n++] = replay_event_to_core_event(&msg.event); break; case ReplaySession: // ← SESSION BOUNDARY // 1. Push accumulated events (picker hooks called synchronously) core_push_events(&sim->core, buf_events, buf_n); // 2. Kick scheduling round (non-blocking signal to picker thread) core_go(&sim->core); // 3. Drain decisions until end-of-session marker (NULL) xce_decision_t* d; int di = 0; while (cbuf_get(&sim->core.decisions, &d) == 0 && d != NULL) { // 4. Compare against recorded decisions (if verify enabled) if (sim->verify && di < msg.session.decisions.len) { xce_decision_t expected = from_replay_decision(msg.session.decisions[di]); if (!decision_eq(&expected, d)) log_mismatch(sim, &expected, d); // --sim-verify di++; } if (sim->record_file) write_replay_decision(sim->record_file, d); // record output free_decision(d); } // 5. End session (GC, reset workers) core_end_session(&sim->core); buf_n = 0; // reset for next round break; case SnapshotEntry: // Mid-replay snapshots (periodic checkpoints): skipped during replay. // Only the initial SnapshotEntry (loaded at startup) is consumed. break; } }The driver thread blocks during the decision drain loop (step 3-4) because
cbuf_get(&decisions)blocks until the allocator produces a decision or the end-of-session marker. Thecore_go()call itself is non-blocking (it only signals the picker thread viacbuf_put(&wbuf)and returns immediately). After all decisions are drained and the NULL marker arrives,core_end_session()runs cleanup, and the driver resumes reading the next message from the.replayfile.Decision ordering constraint:
ReplayDecisionmessages appear within theReplaySession.decisionslist. Their order matches the allocator thread's output order through thedecisionscbuf. AReplayDecisionappearing outside aReplaySessionis a format error. -
Simulation record file: In simulation mode with
--sim-output FILE, the driver shall write a.replayfile in the identical capnp stream format as the input replay file (ReplayHeader→SnapshotEntry→ReplayEvent→ReplaySessioncycles). This record file captures the simulation's actual decisions, enabling:- Diff-based debugging:
diff <(xceadmin scheduler dump original.replay) <(xceadmin scheduler dump simulated.replay)reveals exactly which decisions changed - Tuning feedback loop: Modify picker parameters, re-run simulation, diff the record files to quantify the impact
- What-if comparison: Compare
--sim-alt-eventsrun output against the base run output
The record file starts with a
ReplayHeader(carrying scheduler version + config hash from the sim run, NOT copied from the replay file), followed by the sameSnapshotEntrythat was loaded, then events +ReplaySessioncycles exactly as produced by the core. If--sim-outputis not specified, decisions are printed to stdout only and no record file is written.Symmetry with normal-mode recording: Both the normal-mode driver (when recording is active, AC 10) and the simulation-mode driver (with
--sim-output) produce.replayfiles in the identical capnp stream format. The same file layout, same message types, same decision encoding. This symmetry is intentional:Aspect Normal-mode recording Simulation --sim-outputTrigger scheduler record start(runtime)--sim-output FILE(CLI at startup)Input source Controller capnp events Replay file events Writes events? Yes — records all input events as ReplayEventmessagesYes — passes through events from replay file as ReplayEventmessagesWrites decisions? Yes — records scheduler decisions in ReplaySessionmessagesYes — records simulated decisions in ReplaySessionmessagesOutput file .replayfile (for later simulation).replayfile (for diff analysis)ReplayHeaderoriginLive cluster metadata Sim run metadata SnapshotEntryoriginsave_snapshot(core)at record startSnapshotEntryfrom replay filePurpose Capture reality Capture simulation result This means
diff <(xceadmin scheduler dump original.replay) <(xceadmin scheduler dump simulated.replay)works out of the box — both files have the same structure, same timestamps, same event types. Only the decisions may differ, which is exactly what you want to compare. - Diff-based debugging:
Replay File Format
-
Single-file capnp stream format (
.replay): The replay file shall be a single file containing consecutive Cap'n Proto messages in the standard framing (segment table + data per message). Each message is one of the types below. The file is appendable — recording writes each message as it occurs; no need to know final size upfront.File suffix:
.replay— self-documenting, no collision with schema files (.capnp), configuration blobs (.bin), or text formats (.json,.jsonl).Schema (in
controller.capnp):struct ReplayHeader { recorderVersion @0: Text; # scheduler binary version that wrote this file configHash @1: Data; # sha256 of xce_conf4sched_t cluster @2: ReplayCluster; recordingStartTs @3: UInt64; # logical time recording started (ns) recordingStartWall @4: Text; # wall-clock time recording started (ISO 8601) schedPolicy @5: Text; # active picker plugin name (e.g. "builtin_fcfs") } struct ReplayCluster { name @0: Text; controllers @1: List(Text); # ["host1:9991", "host2:9992", "host3:9993"] recordingHost @2: Text; # controller hostname that made the recording } struct SnapshotEntry { ts @0: UInt64; # logical time of snapshot data @1: SnapshotData; # full core state } struct ReplayEvent { ts @0: UInt64; # logical time body @1: union { configPush @2: Conf4Sched; jobsPush @3: SchedJobData; workersPush @4: WorkerData; resourcesPush @5: ResourceData; resvalsFull @6: ResValList; resvalsDelta @7: ResValList; jobStateChange @8: JobStateChangeEvent; jobUpdate @9: JobUpdateEvent; queueUpdate @10: QueueConf; jobRusage @11: SchedJobRusages; endSession @12: Void; # recording stop marker } } struct ReplaySession { beginRound @0: UInt64; # logical time (ts value) decisions @1: List(ReplayDecision); summary @2: SessionSummary; # optional diagnostic metadata } struct ReplayDecision { ts @0: UInt64; body @1: union { dispatch @2: DispatchDecision; pend @3: PendDecision; preempt @4: PreemptDecision; } } struct SessionSummary { durationNs @0: UInt64; nDecisions @1: UInt32; nDispatched @2: UInt32; nPended @3: UInt32; }File structure: The
.replayfile is a single stream of consecutive capnp messages:[msg 1] ReplayHeader [msg 2] SnapshotEntry ← initial state (MUST be present, MUST be first after header) [msg 3] ReplayEvent ← input event [msg 4] ReplayEvent ← input event ... [msg N] ReplaySession ← BEGIN_ROUND → decisions → SESSION_SUMMARY [msg N+1] ReplayEvent ... [msg M] SnapshotEntry ← periodic checkpoint (every N events, AC 11) ... [msg K] ReplayEvent{endSession} ← recording stop markerReplayHeader: first message. Identifies the file type (.replayfiles start with a known capnp struct, not an ambiguous byte sequence). Carries metadata about the recording origin — recorder version, config hash, cluster identity, timestamps, active scheduling policy.- The
.replayfile is fully self-contained: theReplayHeaderidentifies which config was active (configHash), and the firstSnapshotEntrycarries the full config payload plus all scheduling state. No external config file, controller, or database is needed to replay — one file has everything. SnapshotEntry: the first data message afterReplayHeaderMUST be aSnapshotEntry. The simulation driver shall reject any.replayfile whose second message is not aSnapshotEntry— without initial state, simulation has no starting point. Mid-streamSnapshotEntrymessages (from periodic recording, AC 11) provide seek points for--sim-start-ts.ReplayEvent: input event carrying payload in a discriminated union. Thebodyunion tag selects the event type (configPush,jobsPush, etc.). No separate"type"field needed — the capnp union tag is the type discriminator.ReplaySession: groups aBEGIN_ROUNDtrigger with its resulting decisions and session summary. AReplaySessionbetweenReplayEventmessages marks a session boundary; aReplayDecisionoutside aReplaySessionis a format error. Decision order withindecisionsmatches allocator output order.ReplayEvent{endSession}: final message in a completed recording. Signals clean shutdown. The simulation driver ignores it (replay runs until EOF).- No text parsing, no JSON schema, no two-format code paths — the same capnp decode path reads every message. The simulation driver decodes each message sequentially from the stream.
Human inspection:
xceadmin scheduler dump <FILE>decodes a.replayfile to human-readable text. xceadmin already bundlescontroller.capnpschemas forconfig push/config build—scheduler dumpadds one new subcommand reusing the same schema infrastructure.Recording (append): The
.replayformat is append-friendly —save_snapshot()and event writes serialize one capnp message at a time. Onrecord start: writeReplayHeader+ initialSnapshotEntry. Each event: write oneReplayEvent. Each session: write oneReplaySession. Periodic checkpoint: write oneSnapshotEntry. Onrecord stop: writeReplayEvent{endSession}+ fflush + fclose. Recording failure mid-write: the file is truncated to the last complete message — no partial-message corruption.Simulation (read):
--sim-start-ts NSseeks forward throughSnapshotEntrymessages (deserialize each, checkts <= NS, keep latest match), loads the matching snapshot, then replays all messages withts >= NS.
State Snapshot
-
Snapshot serialization: The core shall support
save_snapshot()producing aSnapshotDatacapnp struct containing all scheduler configuration (xce_conf4sched_t*— queues, priorities, resource thresholds, preemption config, time windows), jobs, workers, resources, resvals, users, projects, shared-resource tracking state, unsatisfiable-resreq cache, PRNG state, and session state. The core shall supportload_snapshot()restoring fromSnapshotDatato that exact state.The snapshot is fully self-contained for simulation. After
load_snapshot()and picker rebuild, the core is ready to processcore_push_events()+core_go()and produce decisions — no other state, external config, or preceding events are needed. Given a.replayfile, the simulation driver can start from anySnapshotEntry(via--sim-start-ts): load the snapshot, rebuild picker, set virtual clock to snapshotts, then replay all messages withts >= snapshot.ts. The result is identical to replaying from the beginning — the snapshot is a complete checkpoint.Picker state is NOT stored in snapshot. The builtin picker's private state (
ordq_t queues, per-queueordq_t pendJobs) is derived from core state: every queue →on_queue_update, every pending job →on_job_push. Afterload_snapshot()restores core state, the driver shall rebuild the picker's private state by walking the core's queues and jobs and calling the picker's state-change hooks. This ensures:- Snapshots capture only canonical core state (no duplicate of picker internals)
- Picker rebuild is deterministic: same core state + same hook sequence = same picker state
- Custom pickers with additional state can rebuild from the same core-state walk
Snapshot contents checklist — everything required to run a scheduling session:
Category Contents Config xce_conf4sched_t*— all queues, priorities, resource thresholds, preemption config, time windowsJobs All job records with current state, priority, queue assignment, queue_time Queues All queue definitions with RUNLIMIT, MAX_RUNNING, state, per-queue picker override Workers All worker records with resources, state, assigned jobs Resources Resource definitions (resreq templates) Resvals Current resource value snapshots for all workers Users User records with resource limits, group membership Projects Project scaffolding with priority (v1: flat, v2: hierarchical quota) Shared-resource Shared resource tracking state across workers Unsatisfiable cache Resreqs that could not be satisfied in previous rounds (for early skip) Session state Current scheduling round, iterator positions, GC state PRNG state Current PRNG value (for deterministic tie-breaking resume mid-file) -
Interactive simulation REPL: In simulation mode with
--interactive(or-i), the scheduler shall open a read-eval-print loop on stdin/stdout after loading the replay file but before the first event is processed. The REPL gives the operator full control over simulation execution — single-step, breakpoints, state inspection, and snapshot capture — turning the simulation driver into a real debugger for scheduling policy.Interaction model: The simulation process blocks on stdin at startup (pre-replay pause) and at each breakpoint hit. No controller, no network, no authentication — this is a local investigation tool. The REPL prompt is
(sim).Minimum command set:
Execution control:
Command Description step [N]Advance N sessions (default 1), pause after. Processes all events up to and including the Nth BEGIN_ROUNDfrom the current position, then returns to REPL.continueRun until next breakpoint hit or EOF. If no breakpoints are set, runs to completion. quitExit the scheduler immediately. No cleanup guarantees beyond fcloseon open files.Breakpoints:
Command Description break <event_type> [key=value ...]Set breakpoint on event type (e.g., JOB_STATE_CHANGE,JOB_PUSH,BEGIN_ROUND,DECISION). Optional key=value filters narrow the match:break JOB_STATE_CHANGE jobid=12345,break JOB_STATE_CHANGE state=RUN,break DECISION type=DISPATCH queue=normal. Multiple filters are AND'd. Key names match the field names in the capnp schema for each message type.info breakList all breakpoints with their IDs, event types, and filters. delete <id>Delete breakpoint by ID. delete allremoves all breakpoints.State inspection:
Command Description info sessionShow current simulation position: logical time ( xce_core_now()), event index (Nth message processed), session count, lastSessionSummaryif available.info job <jobid>Dump one job: state, queue, priority, queue_time, resreq, allocations. Jobid uses the same format as xcecli (e.g., 12345).info queue <name>Dump one queue: priority, state (open/closed), RUNLIMIT, MAX_RUNNING, job_pick_plugin, list of queued job IDs with priorities. info queue alllists all queues.info worker <name>Dump one worker: state, resources (total + available), assigned jobs. info worker alllists all workers.info resource <name>Dump one resource: type, total capacity, available, allocation count. info resource alllists all resources.info summaryQuick overview: N_jobs (M pending, K running), N_workers (M online), N_queues, N_resources.Snapshots:
Command Description snapshot [FILE]Write a full save_snapshot()to FILE as a standalone.replayfile (containsReplayHeader+SnapshotEntry). If FILE is omitted, auto-generate:<replay_basename>-snap-<event_index>.replay. The snapshot is a complete state dump (same format as periodic recording checkpoints). The operator can restart simulation from this snapshot via--sim <snapshot_file> --sim-start-ts <time_at_snapshot>.Help:
Command Description helpPrint available commands with one-line descriptions. Covers all REPL commands: execution control ( step,continue,quit), breakpoints (break,info break,delete), state inspection (info session/job/queue/worker/resource/summary), snapshots (snapshot), sim parameters (set,info config).Simulation parameters (interactive
set/info config):Command Description set speed <N>Set simulation speed from this point forward. 0= as-fast-as-possible (no wall-clock delay),1= real-time,N= N× real-time. Overrides the--sim-speedCLI value.set output <FILE>Start writing simulation decisions to FILE in the same .replaycapnp stream format as--sim-output. OpensReplayHeader+SnapshotEntryat the current position if the file is new. File is written incrementally — each subsequent session's decisions land in the file.set output offClose the current output file. Subsequent decisions are not recorded. info configconfirms the file path and event count at close time.set verify on|offEnable or disable decision verification against the recorded replay file. When enabled, each decision is compared against the corresponding recorded decision at the same logical time. Mismatches are printed immediately. Default: matches --sim-verifyCLI flag.info configShow current sim parameters: `speed=N, output=<path or "off">, verify=on Why interactive parameters matter: Without these, tuning simulation requires restart: change
--sim-speed, re-run from snapshot, re-check results. Withset speedandset output, the operator can fast-forward through known-good regions (set speed 0), slow down for suspicious sections (set speed 0.1), and only start recording output when the interesting behavior starts — all in one session. This turns the REPL from a passive inspector into an active investigation tool where parameters co-evolve with the operator's understanding of the trace.REPL integration with the simulation loop:
load replay file + snapshot if --interactive: print "[sim] loaded: N events, snapshot @ ts=..." print "[sim] ready. type 'help' for commands." repl_pause() // pre-replay pause — operator can set breakpoints for each event in replay_file: // ── sim parameter: speed ── if sim->sim_speed_num > 0: wall_clock_delay(delta_ns * sim->sim_speed_den / sim->sim_speed_num) if check_breakpoints(event): print "[sim] hit breakpoint @ event #I: <event_type> <key>..." repl_pause() core_push_events(core, event) if msg.which == ReplaySession: core_go(core) for each decision: // ── sim parameter: verify ── if sim->verify: compare_and_report(decision, expected) // AC 15; REPL 'set verify on/off' // ── sim parameter: output ── if sim->output_file: write_replay_decision(sim->output_file, decision) // AC 19; REPL 'set output' if check_breakpoints(decision): print "[sim] hit breakpoint @ decision: <type> job=<jobid>..." repl_pause() if --interactive: print "[sim] replay complete. N_sessions processed." if sim->output_file: print "[sim] output written to: <path>"What is NOT in v1:
- Write-side mutations to scheduler state: No
set queue priority,set job state,add job,remove job. Thesetcommand in v1 only tunes simulation parameters (speed,output,verify) — it never mutates core scheduling state. Operator inspects state but cannot alter it. What-if analysis uses--sim-alt-events(AC 16) instead. State mutation from REPL is a follow-up. - Reverse execution: No
step backor time-travel undo. Operator can restart from snapshots captured at earlier points. - Expression evaluation: No
print core->sessions_countor arbitrary C evaluation. Commands are fixed. - Scripting/batch mode: No
source FILEor-c "step 5; info summary". The REPL is interactive-only. For automated simulation runs, use the batch flags (--sim,--sim-output,--sim-verify) without--interactive. - No semantic
snapshot diffin xceadmin:xceadmin scheduler dumpproduces human-readable output. Comparing two dumps (diff <(xceadmin scheduler dump snap1) <(xceadmin scheduler dump snap2)) works but is generic text diff — no scheduler-aware semantic diff ("queue priority changed from 10 to 20", "3 new jobs added"). Semantic diff is a v2 feature.
Deterministic replay is preserved: The REPL never alters core state or event stream. It only controls when events are processed (by pausing), not what events are processed. The same replay file with the same breakpoints ignored (treated as skip) produces identical decisions as batch mode. Interactive use with
stepandcontinuethat runs to completion produces the same output as--simwithout--interactive.Implementation note: The REPL is a single
fgetsloop in the simulation driver. It is not a separate thread — when the REPL blocks on stdin, the simulation loop is parked. This is correct and intentional: the operator is in control, and the simulation has no wall-clock deadlines. - Write-side mutations to scheduler state: No
Full object sharing (resreq template registry, pend reason dedup pool) is deferred to a follow-up spec. However, the architecture must not block sharing from being added later:
-
Resreq immutability: Resreq objects shall be treated as immutable once created. No code path shall mutate a resreq in place. This ensures sharing via
constpointer is safe to introduce later. -
Pend reason factory: All pend reason allocation shall go through
make_pendreason()(the single factory inlib/preason.c). Callers nevercalloc(sizeof(xce_pendreason_t))directly. -
Pointer-identity fast path: Code comparing two resreqs or pend reasons shall use pointer equality (
a == b) as a fast path before deep comparison. -
Select expression memoization: The existing
select_eval_ctx_tper-session memoization shall be preserved and extended. Parsed/evaluated form attached to resreq on first evaluation, reused for subsequent evaluations. -
Snapshot forward-compatibility: The snapshot format shall not preclude object sharing. Shared objects shall be serializable by identity (object table with IDs).
These invariants impose zero overhead today but rule out design choices that would make sharing expensive or impossible to retrofit.
Data Structure Changes Summary
xce_scheduler_t additions
char* job_pick_plugin; // default: "builtin_fcfs" (Req 2)
xce_sched_queue_t additions
char* project_name; // owning project (picker uses internally)
char* job_pick_plugin; // per-queue picker override (NULL = use global)
xce_sched_project_t additions
int32_t priority; // inter-project ordering (future spec)
// future: resource_quota, resource_used, parent_project
xce_job_picker_iface_t (new)
typedef struct {
int (*init)(void* picker, xce_sched_core_t* core);
void (*fini)(void* picker);
int (*begin_session)(void* picker);
xce_sched_job_t* (*next_job)(void* picker);
int (*end_session)(void* picker);
int (*on_dispatch)(void* picker, uint64_t jobid);
int (*on_pend)(void* picker, uint64_t jobid);
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);
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;
Core/Driver split
// Core event (driver → core)
typedef enum { XCE_CORE_EVENT_CONFIG_PUSH = 0, XCE_CORE_EVENT_JOBS_PUSH,
XCE_CORE_EVENT_WORKERS_PUSH, XCE_CORE_EVENT_RESOURCES_PUSH,
XCE_CORE_EVENT_RESVALS_FULL, XCE_CORE_EVENT_RESVALS_DELTA,
XCE_CORE_EVENT_JOB_STATE_CHANGE, XCE_CORE_EVENT_JOB_UPDATE,
XCE_CORE_EVENT_QUEUE_UPDATE, XCE_CORE_EVENT_JOB_RUSAGE,
XCE_CORE_EVENT_BEGIN_ROUND } xce_core_event_type_t;
typedef struct {
xce_core_event_type_t type;
uint64_t ts;
union { /* event-type-specific payload */ } payload;
} xce_core_event_t;
typedef int (*xce_core_decision_cb_t)(void* driver_ctx, xce_decision_t* decision);
typedef struct {
// Scheduling state (moved from xce_scheduler_t)
xce_conf4sched_t* config; var plugins; var jobs, queues, workers, users, projects;
// ... (full state)
// Core/driver boundary
void* driver_ctx;
xce_core_decision_cb_t on_decision;
// Internal threading — cbuf producer/consumer
pthread_t picker_thread, allocator_thread;
cbuf_t wbuf; // driver→picker trigger (int, cap=1)
cbuf_t jbuf; // picker→allocator job handoff (uint64_t, cap=1)
cbuf_t decisions; // allocator→driver decision output (xce_decision_t*, cap=5)
_Atomic int session_active; // 1 during session, 0 between
// Clock virtualization — atomically updated by driver
_Atomic uint64_t virtual_clock_base, real_clock_base, speed_num, speed_den;
uint64_t prng_seed;
// Picker plugin slot
char* job_pick_plugin;
} xce_sched_core_t;
typedef struct {
xce_sched_core_t core;
xcecli_ctx_t apictx;
xce_cluster_config_t cluster_cfg;
dataq_t* pend_updates[2];
// Recording state (driver-layer only — core is unaware)
char* record_file_path; // strdup'd path for status queries (AC 10)
FILE* record_file; // replay file handle (NULL when not recording)
bool recording;
uint64_t record_event_count;
uint64_t record_snapshot_interval;
uint64_t record_start_ts;
} xce_sched_driver_t;
typedef struct {
xce_sched_core_t core;
FILE *replay_file, *alt_events_file, *record_file;
// sim parameters (mutable via REPL 'set' command — AC 22)
uint64_t sim_speed_num, sim_speed_den; // 0/1=fast, 1/1=real-time, N/1=Nx
bool verify; // decision comparison on/off
bool interactive; // REPL active
} xce_sched_sim_driver_t;
// Core interface
int xce_sched_core_init(xce_sched_core_t*, xce_core_decision_cb_t, void* driver_ctx, uint64_t prng_seed);
int xce_core_push_events(xce_sched_core_t*, xce_core_event_t* events, int nevents); // batch push before session
int xce_core_go(xce_sched_core_t*); // NON-BLOCKING: signal picker thread
int xce_core_end_session(xce_sched_core_t*); // GC, reset workers
uint64_t xce_core_now(xce_sched_core_t*); // virtualized clock accessor
int xce_sched_core_save_snapshot(xce_sched_core_t*, SnapshotData* out); // fills out with core state
int xce_sched_core_load_snapshot(xce_sched_core_t*, const SnapshotData* in); // restores from snapshot
void xce_sched_core_fini(xce_sched_core_t*);
Architecture Diagrams
Internal Core Thread Architecture (Multi-Threaded, cbuf-Based)
┌──────────────────────────────────────────────────────────────────┐
│ CORE (process) │
│ │
│ [PICKER THREAD] [ALLOCATOR THREAD] │
│ │
│ cbuf_get(&wbuf) ──┐ cbuf_get(&jbuf) ──┐ │
│ begin_session() │ try_allocate() │ │
│ while next_job(): │ can_preempt() │ │
│ cbuf_put(&jbuf,─┼──► cbuf_put( │ │
│ &jobid) │ &decisions,──► on_decision callback │
│ cbuf_put(&jbuf, 0) │ &decision) │ │
│ end_session() │ (NULL on EOS) │ │
│ ┌─────────────────┘ ┌─────────────────┘ │
│ │ │ │
│ │ wbuf jbuf │ decisions │
│ │ (int) (uint64_t) │ (xce_decision_t*) │
│ │ cap=1 cap=1 │ cap=5 │
│ └─────────────────────────┴──────────────────────────────────────│
│ │
│ [Clock atoms — lock-free] [Core state] │
│ virtual_clock_base queues, jobs, workers │
│ real_clock_base plugins, resources, resvals │
│ speed_num, speed_den picker plugin instance │
│ prng_seed users, projects │
└──────────────────────────────────────────────────────────────────┘
Core Scheduling Loop (per-round)
┌─────────────────────┐
│ DRIVER THREAD │
│ │
│ core_push_events() │ ← batch: CONFIG_PUSH, JOBS_PUSH,
│ picker hooks │ JOB_STATE_CHANGE, QUEUE_UPDATE,
│ called sync │ WORKERS_PUSH, RESVALS_*, etc.
│ │
│ core_go() ──────────┼── cbuf_put(&wbuf, &go)
│ NON-BLOCKING │
│ │
│ while (1): │
│ cbuf_get(&decisions)── block per decision
│ if NULL: break │ ← end-of-session marker
│ on_decision(d) ───┼── publish to controller NOW
│ │ (xcecli_jobdispatch /
│ core_end_session() │ xcecli_set_pend_reason /
│ │ xcecli_preempt)
│ sleep(1) or next │
│ event trigger │
└─────────────────────┘
Core/Driver Architecture
┌──────────────────────────────────────────────────────────────┐
│ DRIVER LAYER │
│ Normal Mode Simulation Mode │
│ controller capnp ──┐ replay.replay ──┐ │
│ handler → events │ virtual clock │ │
│ → publish decisions│ → record/log │ │
│ │ ▲ │ │ ▲ │ │
└───────┼───────┼─────┴──────────────┼───────┼───┴──────────────┘
│ │ decisions │ │ decisions
▼ │ ▼ │
┌──────────────────────────────────────────────────────────────┐
│ SCHEDULER CORE │
│ core_push_event() → picker hooks → picker->next_job() │
│ → try_allocate() → on_decision() → DISPATCH / PREEMPT / PEND │
│ ZERO dependencies on: controller, capnp, network, wall-clock, filesystem │
└──────────────────────────────────────────────────────────────┘
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
- 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 consumesxce_sched_project_tscaffolding (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. The only difference is which picker plugin is configured.