# Brief: scheduler-allocation ## Problem The XCE scheduler has a plugin architecture (`plugin.h`/`plugin.c`, `xce_sched_plugin_t`) but three gaps keep it from being a real framework. First, job selection policy is hardcoded: `sched_job_picker()` sets `job_pick_plugin = "xce_sched_fcfs"` as a literal, so swapping policies requires recompilation. Second, parallel job allocation is a stub: `parjob_allocator.c`'s `my_alloc()` takes `(plugin, ctx)` and immediately `return 0` -- any job with `maxTasks > 1` (routed there by `sched_make_primary_alloc_()`) produces no allocation at all. Third, allocation is eager and one-shot: the sibling `seqjob_allocator.c`'s `my_alloc()` (used when `maxTasks == 1`) calls `sched_collect_workers()` then `xlist_find()` for the first host that satisfies the request and stops -- there is no mechanism to generate alternative host/slot combinations and retry when the first plan fails. The first gap (hardcoded picker) is addressed in the sibling **scheduler-core** spec. This spec, **scheduler-allocation**, closes the other two: the parjob stub and one-shot allocation. ## Current State ### What exists (allocation surface) - **Seqjob allocator** (`seqjob_allocator.c`): handles `maxTasks == 1`. Checks shared resources, collects workers via `sched_collect_workers()`, tries one host, returns first match. No retry with alternative plans. - **Parjob allocator stub** (`parjob_allocator.c`): registered as a plugin but `my_alloc()` is empty (`return 0`). Any job with `maxTasks > 1` silently fails allocation. - **Resource request types** (`resreq.h`): PRIMARY (minTasks/maxTasks/select/rusages/span/same/affinity), COMPOUND (list of primaries), ALTER (list of compounds). The type hierarchy is well-defined but allocation at the PRIMARY leaf lacks iteration abstractions. - **Select expression evaluator** (`resreq.h`): full parser/evaluator with per-session memoization (`select_eval_ctx_t`) and inter-session GC. Already reusable for host filtering. - **Shared resources** (`xce_scheduler_t.sharedInst`/`sharedInUse`): zarray of resource values tracked per scheduling session. - **Allocation dispatch** (`scheduler.c:sched_make_allocation()`, `sched_make_primary_alloc_()`): routes to seqjob or parjob allocator based on `maxTasks`. ### What's missing - **Lazy plan generation**: no `xce_alloc_plan_iter_t` -- plans are not generated at all, let alone lazily - **Lazy host iteration**: no `xce_host_iter_t` -- host collection is eager via `sched_collect_workers()`, materializing the full list before trying any - **Parallel job allocation**: `parjob_allocator` is a stub. No combinatorial plan generation (1host x N, 2hosts x ceil(N/2), ..., Nhosts x 1), no multi-slot host matching - **Unsatisfiable resreq caching**: a batch of similarly-shaped unsatisfiable jobs each re-pays the full allocation cost within a session ## Desired Outcome ### Iterator-Driven Allocation Architecture A lazy `xce_alloc_plan_iter_t` (candidate `(nhosts, slots_per_host)` plans, most-preferred first) composed with a lazy `xce_host_iter_t` (candidate hosts for a given plan, filtered by the existing select-expression evaluator), a `parjob_allocator` implementation that walks the plan iterator to do real combinatorial multi-slot allocation, and a session-scoped unsatisfiable-resreq fingerprint cache so a job whose resreq no plan can satisfy doesn't re-pay full allocation cost for every later job with the same fingerprint in the same scheduling session. ### Three-Layer Iterator Composition ``` ALTER -> OR-of-compounds: first compound that fully allocates wins (unchanged) └─ COMPOUND -> AND-of-primaries: ALL primaries must allocate (unchanged) └─ PRIMARY -> plan_iter -> host_iter -> try_alloc (NEW iterator layer) ``` The iterator composition operates at the **PRIMARY leaf**. COMPOUND and ALTER are composition rules above the iterators — they call into the same primary allocator, unchanged from today. ### Iterator Pipeline ``` xce_alloc_plan_iter_t -- init(resreq) -> next() -> plan -> next() -> ... -> NULL -> fini() │ plan ▼ xce_host_iter_t -- init(plan, workers) -> next() -> worker -> ... -> NULL -> fini() │ worker ▼ try_alloc(worker, plan) -- check slots, shared res, commit or advance ``` ### Key Properties - **Lazy**: `plan_iter->next()` generates one plan at a time. `host_iter->next()` returns one host at a time. No full materialization. - **Cancel-safe**: both `next()` calls have `pthread_cancel` cancellation points. - **Independent lifecycles**: a `host_iter` is created from a plan, used, and destroyed; the `plan_iter` is advanced to the next plan only after the `host_iter` is done. - **COMPOUND/ALTER unchanged**: the composition rules above the PRIMARY leaf are unchanged from today. - **Seqjob rewrap**: `seqjob_allocator` is rewrapped onto the iterator interfaces as a trivial case — its plan iterator always returns the single plan (1 host x 1 slot). This validates the abstraction before applying it to the complex parjob case. ## Alternatives Considered ### Approach A: Incremental Enhancement (rejected) | Aspect | Detail | |--------|--------| | **Plan generation** | Pre-computed plan array. Eager generation for parjob. | | **Host iteration** | No new abstraction. Simple for-loop over `sched_collect_workers()` result. | | **Unsatisfiable 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 (chosen) | Aspect | Detail | |--------|--------| | **Plan iterator** | `xce_alloc_plan_iter_t` — lazy, cancel-safe, generates one plan per `next()` call | | **Host iterator** | `xce_host_iter_t` — lazy per-plan host filtering via existing select-expression evaluator | | **Parallel jobs** | Combinatorial plan generation: 1host x N -> 2hosts x ceil(N/2) -> ... -> Nhhosts x 1 | | **Unsatisfiable resreq** | Session-level fingerprint cache: `{select, rusage, user, queue, project}` | | **Seqjob path** | Trivial rewrap — single plan (1x1) through the same iterator interfaces | **Chosen because:** Lazy generation avoids memory pressure. Iterator composition matches the resreq type hierarchy naturally. Seqjob rewrap validates the abstraction on the simple case before applying it to the complex parjob case. The cache eliminates redundant work for batches of unsatisfiable jobs. ### 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 two-iterator composition (plan -> host -> try_alloc) is a proven pattern: the plan iterator answers "how many hosts, how many slots each?", the host iterator answers "which specific hosts can fulfill this plan?", and the allocator commits or advances. This separation of concerns makes each layer independently testable. ## Approach **Iterator-Driven Allocation**: ### PRIMARY leaf allocation pseudocode ```c int allocate_primary(sched, primary_resreq, ctx) { plan_iter = plan_iter_init(primary_resreq, sched); while ((plan = plan_iter->next(plan_iter)) != NULL) { host_iter = host_iter_init(plan, sched->workers); while ((worker = host_iter->next(host_iter)) != NULL) { if (try_alloc_on_host(worker, plan, ctx) > 0) { host_iter->fini(host_iter); plan_iter->fini(plan_iter); return ALLOC_SUCCESS; } } host_iter->fini(host_iter); } plan_iter->fini(plan_iter); return ALLOC_FAIL; } ``` ### Who uses this composition | Allocator | `maxTasks` | Plan iterator | Host iterator | Behavior | |-----------|------------|---------------|---------------|----------| | `seqjob_allocator` | `== 1` | Trivial: single plan (1 host x 1 slot) | Full: filters hosts by select expression | Unchanged from today — first matching host wins | | `parjob_allocator` | `> 1` | Full combinatorial: 1hostxN, 2hosts x ceil(N/2), ..., Nhosts x 1 | Full: filters hosts with enough available slots | New — walks plans until one succeeds | | Future allocators | any | Custom strategy (e.g., topology-aware, bin-packing) | Reuses same `xce_host_iter_t` | Pluggable via `job_alloc_fn` | ### Unsatisfiable resreq cache ```c // Per-session cache on xce_scheduler_t // Key: fingerprint of {select, rusage, user, queue, project} // Cleared at begin_session() // Populated when a job's plan iterator exhausts without success // Checked before starting allocation for each job — hit = immediate pend ``` ### What stays unchanged - `seqjob_allocator` externally observable behavior — rewrap is internal - COMPOUND and ALTER resreq allocation paths — dispatch is unchanged - Select expression system — no changes - Shared resource tracking — no changes - Existing plugin framework — no changes ### What's new - `xce_alloc_plan_iter_t` — lazy plan iterator interface - `xce_host_iter_t` — lazy host iterator interface - `parjob_allocator` combinatorial plan generation - Session-level unsatisfiable resreq fingerprint cache - Seqjob rewrap onto iterator interfaces (trivial case, behavior unchanged) ## Scope ### In - `xce_alloc_plan_iter_t` interface: `init(resreq, sched)`, `next() -> plan`, `fini()` - `xce_host_iter_t` interface: `init(plan, workers)`, `next() -> worker`, `fini()` - `parjob_allocator` combinatorial plan generation (1host x N, 2hosts x ceil(N/2), ..., Nhosts x 1) via plan iterator - `seqjob_allocator` rewrap onto iterator interfaces (trivial 1-host/1-slot case, behavior unchanged) - Session-level unsatisfiable resreq fingerprint cache (keyed on `{select, rusage, user, queue, project}`) - COMPOUND and ALTER resreq allocation paths unchanged — they compose over the PRIMARY leaf allocator - Unit tests: plan generation, host iteration, cache hit/miss, seqjob rewrap equivalence - Integration test: end-to-end multi-slot parallel job allocation ### Out - Pluggable job picker interface (`xce_job_picker_iface_t`) — in **scheduler-core** - `builtin_fcfs` picker — in **scheduler-core** - Configurable picker plugin with per-queue override — in **scheduler-core** - Pickers in general (Req 1, 5, 8 of scheduler-framework) — in **scheduler-core** - Preemption (Req 6 of scheduler-framework) — in **scheduler-preemption** - Core/driver separation (Req 10 of scheduler-framework) — in **scheduler-core** - Queue vs Project section — in **scheduler-core** - New job selection policies beyond what the picker interface enables — deferred - Resreq template registry (named/shared resreq objects) — deferred - Select expression language or evaluator changes - GPU/topology/affinity-aware allocation — left pluggable via future allocator plugins - Controller-side scheduling-state replication / commit-barrier wiring ## Upstream / Downstream ### Upstream (dependencies) - **scheduler-core** — the plan and host iterators consume the core event interface (`xce_core_event_t`) for worker/resource state updates, but are independently unit-testable with constructed state - Existing plugin framework (`plugin.h`, `plugin.c`) — NO changes needed - Existing select expression evaluator (`resreq.h`) — NO changes needed - Existing shared resource tracking (`sharedInst`/`sharedInUse`) — NO changes needed - Existing resreq type hierarchy (PRIMARY/COMPOUND/ALTER) — NO changes needed ### Downstream (future consumers) - **Affinity/GPU allocators** — register as allocator plugins, implement custom plan iterators for topology-aware placement, reuse `xce_host_iter_t` - **Bin-packing allocator** — custom plan iterator strategy - **Resreq template registry** — the fingerprint cache's key can incorporate template identity when sharing is added ## Existing Spec Touchpoints ### Depends on - **scheduler-core** — the iterators operate within the core's scheduling session, consuming worker and resource state pushed through the core event interface. The iterators do NOT depend on the core's driver or I/O layer — they are purely computational, operating on in-memory state. ### Adjacent - **scheduler-preemption** — preemption fires after allocation failure. The unsatisfiable resreq cache is populated after both allocation and preemption fail (or preemption is inapplicable). The iterator interfaces are not preemption-aware. - **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. ## Constraints - **C (gnu17)** — no C++, no external libraries beyond existing (pthreads) - **Cancel-safe** — `plan_iter->next()` and `host_iter->next()` must have `pthread_cancel` cancellation points - **Session isolation** — iterator state and resreq cache are per-session, cleared on `begin_session` - **Backward compatible** — existing seqjob allocation behavior unchanged. COMPOUND/ALTER dispatch unchanged. New functionality is additive. - **Memory** — lazy generation avoids materializing all combinations. Worst-case for a job needing N slots on M hosts: M x N candidate evaluations (acceptably bounded). - **No controller dependency** — iterators are purely computational, operating on in-memory worker/resource state. They do not perform I/O or call into the controller.