myxce/.kiro/specs/scheduler-allocation/requirements.md
Rongsong Shen d1a3e158ae
docs(scheduler-allocation): fix xce_host_iter_t.next() return type
Found during task 1.1 implementation: the Data Structure Changes
Summary declared next() returning xce_worker_t* (the wire/config
type), but the Composition Algorithm pseudocode in the same document
already used xce_sched_worker_t* (the scheduler-internal type with
resvals/resInUse/host_eval_ctx) for the identical call -- these are
genuinely different types in this codebase, confirmed via
make_sched_worker()'s xce_worker_t* -> xce_sched_worker_t* conversion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYdtrEWWZfKRRGED7raEYg
2026-09-04 13:26:18 +00:00

69 KiB
Raw Blame History

Requirements Document

Introduction

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. The first gap -- job selection policy is hardcoded (sched_job_picker() sets job_pick_plugin = "xce_sched_fcfs" as a literal) -- is addressed in the sibling scheduler-core spec.

This spec closes the remaining two allocation gaps:

  1. 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_() at scheduler.c:93-103) produces no allocation at all.

  2. 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.

This spec introduces an iterator-driven allocation architecture with these components:

  • A lazy xce_alloc_plan_iter_t -- candidate plans (each a partition of the job's task count into per-host slot buckets, e.g. [2,1] for 3 tasks across 2 hosts), most-preferred first
  • A lazy xce_host_iter_t -- candidate hosts for a given plan, filtered by the existing select-expression evaluator
  • A parjob_allocator that walks the plan iterator to do real combinatorial multi-slot allocation
  • 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
  • A seqjob_allocator rewrap onto the iterator interfaces as a trivial 1-plan/1-slot case, validating the abstraction before applying it to the complex parjob case

Full discovery context (problem, current state, alternatives considered, approach, boundary, upstream/downstream) is in .kiro/specs/scheduler-allocation/brief.md.

Boundary Context

  • In scope: xce_alloc_plan_iter_t and xce_host_iter_t interfaces (lazy, cancel-safe, composable); seqjob_allocator rewrapped onto the iterator interface as a trivial 1-plan/1-host-loop case (behavior unchanged, maxTasks == 1 only); parjob_allocator's my_alloc() implementing real combinatorial plan generation (1host x N, 2hosts x ceil(N/2), ..., Nhosts x 1) for maxTasks > 1; xce_alloc_mode_t on xce_sched_ctx_t for allocation mode (NORMAL/PREEMPT); session-level unsatisfiable-resreq fingerprint cache keyed by xce_cache_key_t (O(1) content hash from parsed resreq + identity); unit tests for plan generation, host iteration, cache hit/miss, seqjob rewrap equivalence; integration test for end-to-end multi-slot parallel job allocation.

  • Out of scope: 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; preemption (Req 6 of scheduler-framework) -- in scheduler-preemption; core/driver separation (Req 10 of scheduler-framework) -- in scheduler-core; Queue vs Project roles -- in scheduler-core; new job selection policies beyond the builtin FCFS picker; a resreq template registry (named/shared resreq objects); changes to the select-expression language or evaluator; controller-side scheduling-state replication / commit-barrier wiring; GPU/topology/affinity-aware allocation (left pluggable via future allocator plugins).

  • Adjacent expectations: depends on the existing plugin framework (plugin.h/plugin.c), the existing select-expression evaluator (resreq.h, memoized via select_eval_ctx_t), and existing shared-resource tracking (xce_scheduler_t.sharedInst/sharedInUse) -- none of these change. Depends on scheduler-core for the core event interface that pushes worker and resource state; the iterators are independently unit-testable with constructed state. ha-consistency-invariants' commit barrier is a future consumer of scheduling decisions; this spec does not wire it.

Allocation Architecture (Reqs 2-4)

Requirements 2, 3, and 4 form a three-layer allocation architecture. These layers compose according to the job's resource request type (PRIMARY, COMPOUND, or ALTER):

Resreq Type Hierarchy

ALTER     ->  OR-of-compounds: first compound that fully allocates wins
  └─ COMPOUND ->  AND-of-primaries: ALL primaries must allocate for success
       └─ PRIMARY  ->  plan_iter -> host_iter -> try_alloc  (leaf level)

The iterator composition (xce_alloc_plan_iter_t + xce_host_iter_t) operates at the PRIMARY leaf. COMPOUND and ALTER are composition rules above the iterators -- they call into the same primary allocator, unchanged from today's sched_make_compound_allocation() and sched_make_alter_allocation() dispatch.

Iterator Composition (PRIMARY leaf)

                    ┌──────────────────────────┐
                    │  xce_alloc_plan_iter_t    │  Req 2
                    │                            │
                    │  init(primary_resreq, sched)  │
                    │  next() -> plan            │  generates per-host bucket
                    │  fini()                    │  partitions, most-preferred first
                    └────────────┬───────────────┘
                                 │ plan
                                 ▼
                    ┌──────────────────────────┐
                    │  xce_host_iter_t          │  Req 3
                    │                            │
                    │  init(plan, workers)       │  for a given plan, iterate
                    │  next() -> worker           │  candidate hosts matching
                    │  fini()                    │  select expression + availability
                    └────────────┬───────────────┘
                                 │ worker
                                 ▼
                    ┌──────────────────────────┐
                    │  try_alloc(worker, plan)  │  Req 4 (parjob) or
                    │                            │  existing seqjob logic
                    │  check slots, shared res,  │
                    │  commit or advance         │
                    └────────────────────────────┘

Composition Algorithm (All Three Types)

A plan is a partition of the primary's task count into per-host buckets (e.g. for maxTasks=3: [3], [2,1], [1,1,1]) -- not a single (nhosts, slots_per_host) pair, which cannot express uneven splits. allocate_primary() must therefore satisfy every bucket in a plan against a distinct host before the plan counts as satisfied; satisfying only the first bucket and stopping (as a single-pass try_alloc_on_host loop would) is not a valid implementation of Requirement 4 -- it degenerates into the single-host plan and never actually exercises the multi-host case.

Host exclusivity within a plan is enforced by the caller, not xce_host_iter_t itself: allocate_primary() skips any host already present in claimed before calling try_alloc_on_host -- Requirement 3's init(plan, workers) signature is unchanged, host_iter stays a pure select+availability filter. This distinction matters: a host offered and rejected for one bucket (too small for that bucket's slot count) must remain eligible for a later, smaller bucket in the same plan -- only a host actually claimed by a bucket is permanently excluded for the rest of that plan attempt.

Correction (this review): a fresh host_iter per bucket, not one shared across the whole plan. An earlier version of this pseudocode created one host_iter before the bucket loop and let every bucket's while pull from the same cursor. Since next() is a forward-only cursor with no rewind, that silently breaks the "offered-and-rejected stays eligible for a later bucket" guarantee just stated: a host bucket 0 tries and rejects (too small for it) is gone from the cursor forever, even if it exactly satisfies bucket 1. Concretely, for plan [2,1] with host A (1 free slot) and host B (2 free slots): bucket 0 ("need 2") pulls A first, rejects it, pulls B, succeeds -- cursor now exhausted; bucket 1 ("need 1") gets nothing back even though A was sitting there the whole time. The fix: host_iter_init(iter, plan, sched->workers) is called fresh at the top of each bucket iteration (same plan, same host_filter every time -- only is_claimed()'s growing exclusion set differs call to call), and fini()'d at the end of that same bucket's search. This is a three-level nested loop (plan → bucket/partition → worker, the third level always starting fresh), not a two-level one with an inner index.

Bucket order is not merely a fail-fast heuristic -- for v1's single-resource-dimension model, descending (largest bucket first) can succeed where ascending would fail, with the identical set of available hosts. Same example: with buckets tried ascending [1,2] instead, a fresh scan for "need 1" could greedily claim host B (2 free slots -- trivially satisfies 1, but wastefully overqualified), leaving only A (1 free slot) for "need 2", which fails. Descending order avoids this by construction: the largest ask is checked against the full pool before a smaller ask has a chance to consume a host only the larger one could have used. This is why the v1 default (descending, "Data Structure Changes Summary" below) is a real correctness property here, not just an optimization -- and why generating the reverse order as a second candidate plan for the same partition isn't needed on top of it. Which specific host gets picked among multiple hosts that satisfy the same bucket (host_iter's own internal candidate order, HOST_ORDER_ANY/HOST_ORDER_MOST_FREE etc.) is a separate, softer question already deferred as future optimization -- it affects efficiency under adversarial cases, not whether a solvable case gets missed outright the way the shared-cursor bug did.

Rollback scope. ctx->uncomitted (sched_ctx_init/sched_ctx_fini, schedobj.c) is per-job-allocation-attempt scratch state, torn down at the end of one job's sched_make_primary_allocation()/sched_make_compound_allocation() call -- it never touches the real, session-wide resInUse books, which are only charged later, separately, once a job actually dispatches (job->accounts/sched_account_jobs()). So rollback_plan_allocation() can never corrupt cluster-wide resource state. It is still required, though: without it, a bucket that succeeded before a later bucket exhausted the host iterator leaves its provisional debit in ctx->uncomitted.hosts[idx] for the rest of that job's allocation attempt -- a later plan considering the same host would see it as falsely less available, purely from a phantom debit an earlier, abandoned plan left behind, which could wrongly pend a job a later plan could actually have satisfied. The reversal itself has no existing precedent to reuse (sched_make_compound_allocation()'s own on-failure path, scheduler.c:206-238, does not actually clean up partial state on a mid-loop failure -- a separate, pre-existing gap outside this spec's scope, tracked separately, not modeled here). The underlying subtract primitive already exists (xvec_op(v1, v2, XVEC_SUB_FLOAT), xvec.c); it just needs a small new "uncommit" helper wired to it, mirroring the existing XVEC_ADD_FLOAT commit call in sched_try_alloc_on_host_(). For this to work, each claimed[] entry must retain the exact alloc_req.host snapshot captured when that bucket succeeded (needed for the merge step too -- see "Final allocation result" below), since ctx->alloc_req is reset and repopulated for every subsequent host tried.

Allocation convention (this review): iterators are stack-allocated by the caller, then initialized in place -- matching sched_ctx_init(&ctx, ...)'s existing convention in this codebase, NOT a heap-allocating factory. init()'s own declared signature (int (*init)(xce_alloc_plan_iter_t* iter, ...), "Data Structure Changes Summary" below) takes the iterator's own address as its first argument for exactly this reason. An earlier version of this pseudocode wrote plan_iter = plan_iter_init(primary_resreq, sched) as if it were a 2-arg factory function returning a new iterator -- inconsistent with the 3-arg in-place-init method the struct actually declares. Corrected below.

// === PRIMARY leaf: iterator composition ===
// Returns >0 on success, 0 on failure
int allocate_primary(sched, primary_resreq, ctx) {
    xce_alloc_plan_iter_t plan_iter_storage;
    xce_alloc_plan_iter_t* plan_iter = &plan_iter_storage;
    if (plan_iter->init(plan_iter, primary_resreq, sched) != 0) {
        return ALLOC_FAIL;  // exact error-propagation contract: design.md
    }

    while ((plan = plan_iter->next(plan_iter)) != NULL) {
        // Correction (kiro-validate-design pass): sized by maxTasks, not an
        // undefined fixed constant -- nbuckets can never exceed maxTasks
        // (each bucket needs >=1 slot), so this is the true, tight upper
        // bound. A fixed compile-time guess risks either overflow for a
        // large parallel job or wasted stack for a small one.
        xce_sched_worker_t* claimed[primary_resreq->maxTasks];
        int nclaimed = 0;
        int plan_ok = 1;

        // Three levels of iteration: plan (outer while, above) -> bucket/
        // partition (this for) -> worker (host_iter, fresh per bucket, below).
        for (int b = 0; b < plan->nbuckets; b++) {
            // Correction (this review): a FRESH host_iter per bucket, not
            // one shared across the whole plan -- next() is a forward-only
            // cursor, so sharing it would drop any host an earlier, larger
            // bucket tried and rejected, even if it exactly satisfies THIS,
            // smaller bucket. Same plan (same select-expression host_filter)
            // every call; only is_claimed()'s growing exclusion set differs.
            xce_host_iter_t host_iter_storage;
            xce_host_iter_t* host_iter = &host_iter_storage;
            xce_sched_worker_t* w;
            int bucket_ok = 0;

            if (host_iter->init(host_iter, plan, sched->workers) == 0) {
                while ((w = host_iter->next(host_iter)) != NULL) {
                    if (is_claimed(claimed, nclaimed, w)) {
                        continue;
                    }
                    if (try_alloc_on_host(w, plan->bucket_slots[b], ctx) > 0) {
                        // snapshot ctx->alloc_req.host here (it is reset on
                        // the next plugin_prepare_allocation() cycle) --
                        // needed for both rollback below and the merge step
                        // in commit_plan_allocation().
                        claimed[nclaimed++] = capture_claim(w, ctx);
                        bucket_ok = 1;
                        break;
                    }
                }
                host_iter->fini(host_iter);
            }
            // host_iter->init() failing is treated the same as "no
            // candidates" for this bucket -- an infrastructure error, not a
            // resource-availability determination; see design.md for the
            // exact error-propagation contract.

            if (!bucket_ok) {
                plan_ok = 0;
                break;  // this bucket has no satisfying host -- abandon the plan
            }
        }

        if (plan_ok) {
            commit_plan_allocation(claimed, nclaimed, ctx);
            plan_iter->fini(plan_iter);
            return ALLOC_SUCCESS;
        }

        // Partial success within this plan attempt must not leak into the
        // next plan attempt for the SAME job (ctx->uncomitted persists across
        // plan attempts until sched_ctx_fini() -- see "Rollback scope" above):
        // for each claimed host, subtract back exactly the debit
        // sched_try_alloc_on_host_() added via XVEC_ADD_FLOAT, using the
        // snapshot captured above (new xvec_op(..., XVEC_SUB_FLOAT)-based
        // helper -- no existing "uncommit" primitive to call into).
        rollback_plan_allocation(claimed, nclaimed, ctx);
    }
    plan_iter->fini(plan_iter);
    return ALLOC_FAIL;
}

// === COMPOUND: AND of primaries ===
// ALL primaries must allocate. On any failure, roll back prior allocations
// and return failure. NOTE: today's sched_make_compound_allocation() does
// NOT actually do this rollback (see bug-1766, found during this spec's
// review) -- rollback_compound_allocations() below is new/corrective
// behavior this spec should introduce, not a port of existing logic. Out of
// this spec's own boundary per Req 9 AC2 (COMPOUND/ALTER dispatch
// unchanged) unless the user elects to fold bug-1766's fix in here.
int allocate_compound(sched, compound_resreq, ctx) {
    for (int i = 0; i < compound_resreq->primaries.count; i++) {
        primary = compound_resreq->primaries[i];
        if (allocate_primary(sched, primary, ctx) <= 0) {
            // Roll back all primaries allocated so far in this compound
            rollback_compound_allocations(ctx, i);
            return ALLOC_FAIL;
        }
    }
    return ALLOC_SUCCESS;
}

// === ALTER: OR of compounds ===
// First compound that fully allocates wins. Same logic as today's
// sched_make_alter_allocation(). On failure, no rollback needed
// (prior compounds were never committed -- they failed their own checks).
int allocate_alter(sched, alter_resreq, ctx) {
    for (int i = 0; i < alter_resreq->compounds.count; i++) {
        compound = alter_resreq->compounds[i];
        if (allocate_compound(sched, compound, ctx) > 0) {
            return ALLOC_SUCCESS;
        }
        // This compound failed -- advance to next alternative
    }
    return ALLOC_FAIL;
}

// === Top-level dispatch ===
// Unchanged from today's sched_make_allocation() dispatch on obj->tag.
int allocate_job(sched, job, ctx) {
    switch (job->resreq_type) {
    case PRIMARY:
        return allocate_primary(sched, job->primary_resreq, ctx);
    case COMPOUND:
        return allocate_compound(sched, job->compound_resreq, ctx);
    case ALTER:
        return allocate_alter(sched, job->alter_resreq, ctx);
    }
}

COMPOUND rollback note: If primaries 1 and 2 of a compound allocate but primary 3 fails, primaries 1 and 2 should be rolled back -- the compound is meant to be atomic. Correction (this review): today's sched_make_compound_allocation() does not actually do this -- see bug-1766. It shares one ctx across the whole primaries loop, breaks on the first failing primary without undoing earlier successes, and its unconditional post-loop if (ctx.alloc != NULL) MOVE_PTR(obj->alloc, ctx.alloc) can attach a partial, orphaned allocation to a compound job reported as failed. This spec's iterator rework does not touch that path (Req 9 AC2 keeps COMPOUND/ALTER dispatch unchanged) -- the pre-existing gap is tracked separately in bug-1766, not fixed here, unless scope is deliberately expanded to include it.

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: 1host x N, 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

Key Design 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 (Req 2 AC 6 / Req 3 AC 6 -- a testable requirement, not just a design intent, since neither loop has a natural cancellation point of its own).
  • Independent lifecycles: a host_iter is created from a plan, used, and destroyed -- and, for a multi-bucket plan, this happens once per bucket, not once per plan (a fresh instance each time, per Req 3 AC 3/4). The plan_iter is advanced to the next plan only after every bucket's host_iter for the current plan is done. fini() on one does not affect the other.
  • COMPOUND/ALTER unchanged: the composition rules above the PRIMARY leaf (AND for compound, OR for alter) are unchanged from today. The iterator interfaces only change what happens inside allocate_primary().
  • 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), and the inner loop is identical to today's first-match logic. This validates the abstraction before applying it to the complex parjob case.
  • Pluggable plan generation, shared host iteration: plan generation is allocator-specific — each job_alloc_fn brings its own strategy (trivial, combinatorial, topology-aware, bin-packing) and creates its own xce_alloc_plan_iter_t internally. Host iteration is shared infrastructure — xce_host_iter_t has one implementation reused by all allocators, parameterized by sort order (HOST_ORDER_ANY, HOST_ORDER_MOST_FREE, etc.). Swapping allocation strategy = swapping the plugin; no core loop change.

Requirements

Requirement 2: Lazy allocation plan iterator

Objective: As a maintainer of the scheduler's allocator plugins, I want a reusable lazy plan-generation abstraction, so that every allocator (current and future) can propose alternative host/slot combinations without materializing the full combinatorial space up front.

Acceptance Criteria

  1. The Scheduler shall define an xce_alloc_plan_iter_t interface with init(resreq, sched), next() -> plan, and fini() operations. The init() signature passes the full xce_primary_resreq_t (including span, same, affinity) and scheduler state — sufficient for any allocator to derive its plan space without future interface changes.

    Plan-shaping fields (on xce_primary_resreq_t, consumed by plan iterator internally):

    • span: bounds host count (nhosts_min/nhosts_max) and per-host task cap (max_per_host, analogous to LSF ptile). v1 combinatorial generation ignores these (empty structs); fill when ptile/nhosts support is needed.
    • same: co-location constraints ("all tasks on same rack"). v1 ignores; future.
    • affinity: restrict to named host group. v1 ignores; future.
    • minTasks/maxTasks: task count bounds — the primary driver of plan space. Used in v1.
    • select: host filter expression — consumed by host iterator (Req 3), not plan iterator.
    • rusages: resource requests — consumed by try_alloc_on_host, not plan iterator.

    v1 implements simple combinatorial generation: 1host×N, 2hosts×ceil(N/2), ..., Nhosts×1. Future allocators read span/same/affinity to constrain the plan space without touching the init() signature.

  2. Each call to next() shall generate exactly one candidate plan -- a partition of maxTasks into per-host buckets (xce_alloc_plan_t.bucket_slots[], see "Composition Algorithm" above; NOT a uniform (nhosts, slots_per_host) pair, which cannot express uneven splits) -- without generating or materializing any other plan in the same call.

  3. The Scheduler shall order plans returned by successive next() calls from most-preferred to least-preferred, per the owning allocator's preference policy.

  4. next() shall return an end-of-iteration signal (not a plan) once every candidate plan has been produced, and this signal shall be stable across repeated calls after exhaustion.

  5. Calling fini() shall release all resources held by the iterator, and the interface shall define no other required cleanup step.

  6. next() shall call pthread_testcancel() (or otherwise provide an equivalent, real cancellation point) at least once per invocation, matching the existing convention sched_job_picker()/sched_allocator() already establish at the top of their own loops (scheduler.c). This is required because, unlike those threads' blocking cbuf_get() calls (a POSIX cancellation point via pthread_cond_wait), combinatorial plan generation is fast, non-blocking pure computation with no cancellation point of its own -- an implementation satisfying AC 1-5 without this would silently violate the "Cancel-safe" design property stated for this iterator without failing any other criterion.

Requirement 3: Lazy host candidate iterator

Objective: As a maintainer of the scheduler's allocator plugins, I want a reusable lazy host-candidate abstraction for finding a host satisfying one plan bucket at a time, so that host filtering reuses the existing select-expression evaluator instead of each allocator reimplementing worker collection and filtering. (Correction, this review: NOT "one xce_host_iter_t per plan" -- a plan with multiple buckets requires a fresh instance per bucket, per Req 4 AC 2.)

Acceptance Criteria

  1. The Scheduler shall define an xce_host_iter_t interface with init(plan, workers), next() -> worker, and fini() operations.

  2. Each call to next() shall return the next candidate host satisfying the given plan's host filter (select expression plus resource availability), evaluated against the existing select-expression evaluator without changes to that evaluator.

  3. next() shall return NULL once no further candidate host exists for the current bucket's search, and the caller shall not need to call next() again on that same host_iter instance after receiving NULL. This does NOT mean the caller is done with the plan: a plan with more buckets remaining requires a fresh host_iter instance (a new init() call) for the next bucket, per Req 4 AC 2 -- exhaustion is scoped to one host_iter instance, not to the plan as a whole.

  4. The host iterator shall compose with the plan iterator: a new xce_host_iter_t shall be constructible from any plan produced by Requirement 2's xce_alloc_plan_iter_t.next() -- and, for a multi-bucket plan, constructible repeatedly from the same plan, once per bucket (Req 4 AC 2), each a fully independent instance.

  5. Calling fini() shall release all resources held by the host iterator, independent of the plan iterator's lifecycle.

  6. next() shall call pthread_testcancel() (or otherwise provide an equivalent, real cancellation point) at least once per invocation, for the same reason as Requirement 2 AC 6: scanning/filtering candidate hosts is non-blocking pure computation with no cancellation point of its own, so this must be explicit rather than assumed.

Requirement 4: Parallel job combinatorial allocation

Objective: As a cluster operator submitting a job that needs multiple task slots, I want parjob_allocator to actually allocate across hosts, so that jobs with maxTasks > 1 are no longer silently unallocatable.

Acceptance Criteria

  1. When a job's xce_primary_resreq_t.maxTasks > 1 reaches parjob_allocator's job_alloc_fn (my_alloc() in parjob_allocator.c), the Scheduler shall generate candidate plans covering at minimum the host-count sequence 1..N hosts, where each plan's bucket sizes are a partition of maxTasks into that many buckets (e.g. for N=3: [3], [2,1], [1,1,1] -- balanced ceil/floor split, not a uniform slots_per_host that cannot express uneven splits), using Requirement 2's plan iterator.

  2. For each candidate plan, the Scheduler shall use Requirement 3's host iterator to find, for every bucket in the plan, a distinct host with enough available slots to satisfy that bucket -- accumulating claimed hosts one bucket at a time -- and shall attempt allocation before advancing to the next plan. A plan is satisfied only when every one of its buckets has a claimed host; if any bucket exhausts the host iterator without a match, the plan fails as a whole and any buckets already matched within that same plan attempt are released before advancing to the next plan (see Composition Algorithm's rollback_plan_allocation).

    A new xce_host_iter_t instance shall be constructed for each bucket (three levels of iteration: plan -> bucket/partition -> worker, the third always starting fresh) -- never one shared, continuously-advancing instance reused across every bucket of a plan. Reusing one instance is an incorrect implementation: next()'s forward-only cursor would permanently drop any host an earlier, larger bucket tried and rejected as too small, even when that same host exactly satisfies a later, smaller bucket -- silently failing plans a correct implementation would have satisfied. Only is_claimed() (Criterion 2's own "distinct host" exclusion, applied by the caller across each bucket's fresh scan) may exclude a host across buckets; rejection by an earlier bucket's size check must not.

  3. When every bucket of a plan is satisfied, the Scheduler shall commit that allocation as a single xce_job_alloc_t merging one xce_host_alloc_t per claimed bucket/host (nhosts == the plan's bucket count; see "Final allocation result" under parjob_allocator changes, below) and shall not evaluate any remaining plan for that job.

  4. When no plan among those generated in Criterion 1 can be satisfied, my_alloc() shall return the same not-satisfied signal (0) that the current stub returns today, so pend-reason handling in sched_make_primary_alloc_() is unaffected.

  5. parjob_allocator's job_alloc_fn shall no longer unconditionally return 0 regardless of ctx contents -- the current stub behavior -- for any job with maxTasks > 1.

  6. Before each candidate host is checked against a plan's bucket, the Scheduler shall set ctx->nslots (and ctx->nhosts, for the shared/global-resource pass) to that bucket's slot count so that parjob_prepare_allocation() computes the per-host resource want via the existing calc_wanted_host_resvals(..., ctx->nhosts, ctx->nslots) call -- the same mechanism seqjob_prepare_allocation() already uses, currently hardcoded to 1, 1. Concurrently, seqjob_prepare_allocation() shall gain a primary->maxTasks == 1 guard (it currently has none, unlike parjob_prepare_allocation(), which already gates on maxTasks). This guard is required, not optional cleanup: plugin_prepare_allocation() (plugin.c:591) unconditionally calls every registered plugin's prepare_allocation_hook, and xvec_idval_store() (xvec.c:247) appends (idx, val) pairs with no dedup by resource idx -- so once parjob_prepare_allocation() stores real values, an unguarded seqjob_prepare_allocation() would also fire for the same maxTasks > 1 job and append a second, wrong (nslots=1) entry for the same resource idx into ctx->alloc_req, corrupting the availability check that Criterion 2 depends on. This is a latent defect in the existing fan-out, invisible today only because parjob_prepare_allocation() is currently a no-op.

  7. Shared-resource satisfaction (sched_check_shared_satisfy(), ctx->alloc_req.shared) shall be computed and checked once per primary resreq -- independent of which plan or bucket-shape is currently being tried -- exactly as it already is today for seqjob_allocator. Plan/bucket iteration (Criteria 1-3) governs only per-host slot allocation; it does not change when or how often the job-level shared-resource check runs.

Requirement 7: Session-level unsatisfiable resreq cache

Objective: As a maintainer of scheduling-loop performance, I want jobs with a resreq the current session already proved unsatisfiable to skip allocation immediately, so that a batch of similarly-shaped unsatisfiable jobs doesn't each re-pay full plan/host iteration cost.

Acceptance Criteria

  1. The Scheduler shall maintain a per-session unsatisfiable-resreq fingerprint cache on xce_scheduler_t, keyed by xce_cache_key_t — a value type composing xce_resreq_fingerprint_t (pre-computed {minTasks, maxTasks, select_node_id, rusage_hash}) and identity ({user, queue, project} as xstring pointers). The resreq fingerprint is stored on the scheduling object (xce_primary_schedobj_t) and computed once on job ingest. The full cache key is constructed on the stack at lookup time and looked up via a single key_find() call against a kvtab_t bound to a new cache_key_ops (kv_key_ops_t) — this codebase's real hash-table API binds hash/cmp/assign/destroy to the table at creation, not per call (see "New types" below; no free-function kvtab_lookup() taking hash/eq params exists). Lookup itself is O(1): no list walks, no string traversals; key_find() does not allocate (only key_add(), on insert, allocates the heap-owned key copy). This captures everything the allocator and limit plugin consume.

  2. The Scheduler shall clear the unsatisfiable-resreq cache at the start of every scheduling session (begin_session), by placing the clear inside core_begin_session_internal() (core_session.c) alongside the existing, unlocked core_reset_shared_resval()/core_reset_workers() calls -- a core-level function both driver.c (normal mode) and sim_loop.c/sim.c (simulation mode) reach identically through xce_core_begin_session(), so no driver-specific clearing code is needed for either mode. No explicit lock is required: this matches the existing, already-safe pattern those two sibling resets use, which relies on xce_core_begin_session()'s own documented ordering guarantee -- the driver completes all begin-session reset work strictly before xce_core_signal_go() unblocks the picker thread (the one cbuf_put() that lets the picker/allocator touch anything for the new session), so the mutex/condvar inside that cbuf_put()/cbuf_get() pair provides the memory-barrier the reset needs, the same way it already does for worker resvals/sharedInUse. Neither driver touches the cache object anywhere else -- lookup (AC 4) and insert (AC 3) happen exclusively on the allocator thread, inside allocate_primary().

    Not part of the snapshot/replay wire format. The cache is pure in-memory, session-scoped state -- sched_snapshot_data_t (snapshot.c/typemap.h) does not capture it, and none should be added. Since AC 2 unconditionally clears it at the next begin_session regardless of what a loaded snapshot contained, any captured cache state would be discarded before a single lookup against it could occur -- persisting it would be dead weight with no observable effect.

  3. When a job's primary resreq's plan iterator (Requirement 2) is exhausted without a successful allocation, the Scheduler shall populate the cache with that resreq's fingerprint before pending the job. In v1, cache population is preemption-agnostic: it fires on allocation failure alone. When the scheduler-preemption spec ships, preemption fires between allocation failure and cache population — if preemption succeeds, no cache entry is created; if preemption fails or is inapplicable, the cache entry is populated as normal. The allocation layer does not call into preemption; the caller (scheduling loop) orchestrates the sequence.

  4. Before starting full allocation for a job, the Scheduler shall look up the job's resreq fingerprint in the cache; on a hit, it shall pend the job immediately without invoking the plan or host iterators or preemption.

  5. A cache hit shall attribute the same pend reason category as the original unsatisfiable determination, so operators are not misled about why a later job was skipped.

Note on resreq_fp and snapshot restore (informational, not an AC): resreq_fp is computed at job-ingest time (core_build_schedobj()/core_events.c) but not by snapshot_load.c's independent local schedobj reconstruction (scheduler-snapshot-job-allocation spec) -- so a session restored via xce_scheduler -i -s <replay> would run with a permanently-cold cache for this Requirement. Confirmed (xce_sched_core_load_snapshot()'s 12 call sites are all in sim.c; none in driver.c) that this can only ever affect offline simulation/investigation sessions, never live production scheduling throughput -- so this is not elevated to a formal Acceptance Criterion. Tracked in bug-1767 alongside the broader recurring-duplication pattern in that reconstruction path, should someone want to close it later.

Requirement 9: No regression to existing allocators or HA soak

Objective: As a maintainer, I want the iterator-driven rework scoped so that existing single-slot allocation and COMPOUND/ALTER dispatch are unaffected, so that this framework change is safe to ship independently of any new allocator.

Acceptance Criteria

  1. Seqjob regression: seqjob_allocator's externally observable allocation behavior (which host a maxTasks == 1 job lands on, given the same worker/resource state) shall be unchanged after it is rewrapped onto the xce_host_iter_t interface.

  2. COMPOUND/ALTER unchanged: The COMPOUND and ALTER resreq allocation paths (sched_make_compound_allocation, sched_make_alter_allocation in scheduler.c) shall remain unchanged, continuing to call into the per-primary allocation path (now iterator-driven) exactly as they do today.

  3. Unit tests: Existing scheduler unit tests (scheduler meson test suite) shall continue to pass unmodified in assertions, adjusted only where they directly exercised the eager-one-shot behavior or stub return value this feature replaces.

  4. HA soak: A full HA soak (sequential and chaotic /xce-test-ha regression) 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.

  5. Allocation-mode extension point: The Scheduler shall add an xce_alloc_mode_t enum (XCE_ALLOC_NORMAL = 0, XCE_ALLOC_PREEMPT) and an alloc_mode field on xce_sched_ctx_t, defaulting to XCE_ALLOC_NORMAL. This spec's own plan/host iterators and try_alloc_on_host() shall not branch on any value other than XCE_ALLOC_NORMAL -- implementing what XCE_ALLOC_PREEMPT (or any future mode) actually does to resource-availability checks is scheduler-preemption's job, not this spec's. The field exists here, in this spec's own scope, purely so scheduler-preemption and later resource-pool modes (e.g. reservation-aware backfill) can extend xce_sched_ctx_t and the availability-check call sites without a further interface change to types this spec already owns -- avoiding a sibling spec's first task being "go modify scheduler-allocation's struct." Adding further enum values beyond the two declared here is explicitly out of this spec's scope.

Note (informational, not an AC): Requirement 7's resreq_fp field is only ever populated on the live job-ingest path in this spec's v1 -- snapshot_load.c's independent restore path does not compute it (see Requirement 7's note above and bug-1767). Since that path is exclusively simulation/investigation tooling (sim.c, never driver.c), this does not warrant a dedicated /xce-audit-invariants scheduler pass as a formal AC here; ordinary unit-test coverage of Requirement 7's cache hit/miss behavior is sufficient for this spec's scope.

Data Structure Changes Summary

xce_alloc_plan_iter_t (new, Req 2)

typedef struct xce_alloc_plan_iter_s xce_alloc_plan_iter_t;

struct xce_alloc_plan_iter_s {
    // Initialize the iterator for a given primary resreq and scheduler state.
    // Returns 0 on success, nonzero on error.
    int  (*init)(xce_alloc_plan_iter_t* iter, xce_primary_resreq_t* resreq,
                 xce_scheduler_t* sched);

    // Return the next candidate plan, or NULL when exhausted.
    // The returned plan pointer is valid until the next next() or fini() call.
    xce_alloc_plan_t* (*next)(xce_alloc_plan_iter_t* iter);

    // Release all resources held by the iterator.
    void (*fini)(xce_alloc_plan_iter_t* iter);

    // Private state: current position, strategy-specific data
    void* private;
};

// A plan is a partition of maxTasks into per-host buckets, e.g. for
// maxTasks=3: [3], [2,1], [1,1,1] -- NOT a uniform (nhosts, slots_per_host)
// pair, which cannot express uneven splits.
typedef struct {
    int  nbuckets;       // number of hosts this plan needs (== len(bucket_slots))
    int* bucket_slots;   // partition of maxTasks; bucket_slots[i] = slots
                         // needed on the i-th distinct host claimed for this plan
    // host_filter: derived from primary_resreq->select, plus slot availability
} xce_alloc_plan_t;

// Generating which host-counts to try, in what order (plan-shape policy) and
// how to partition maxTasks for a given host-count (partition policy) are
// deliberately left as internal, swappable policy inside the plan iterator's
// reference implementation -- not fixed by this interface. v1 ships ONE
// concrete, fully-specified, deterministic default of each -- "swappable
// later" does not mean "unspecified now":
//
//   Plan-shape order (v1 default): ascending host-count, k = 1, 2, ..., N
//   (N = maxTasks). Most-consolidated (fewest hosts) tried first, most-spread
//   (N hosts x 1 slot) tried last -- the brief's original "1hostxN, 2hostsx
//   ceil(N/2), ..., Nhostsx1" sequence, generalized to real partitions below.
//
//   Partition order (v1 default), for a given host-count k: the canonical
//   BALANCED split -- (N mod k) buckets get ceil(N/k) slots, the remaining
//   (k - N mod k) buckets get floor(N/k) slots. E.g. N=7, k=3 -> [3,2,2].
//   No other partition of N into k parts is generated (bounds plan count to
//   exactly N, not the full integer-partition combinatorial space).
//
//   Bucket order within one plan (v1 default): descending by slot count --
//   largest bucket first, so a plan fails fast on its hardest-to-place chunk
//   rather than discovering that late. A simplest-deterministic tie-break,
//   not itself claimed as an optimization.
//
// This is deliberately "blind": v1's plan-shape and partition choices are a
// pure function of maxTasks alone -- they never inspect sched->workers'
// actual free-slot counts, topology, or load when deciding WHAT to generate
// or in WHAT order (only try_alloc_on_host/Req 3's host_iter ever look at
// live worker state, to decide whether a generated plan succeeds). Accepted
// consequence: a job with a very large maxTasks (e.g. thousands) on a modest
// cluster generates up to N candidate plans/host-counts in v1, most of which
// exceed the live worker count and fail immediately once host_iter finds too
// few candidates -- cheap per attempt, but not free, and not capped by
// anything cluster-state-aware. Real optimization -- e.g. skipping a
// host-count already known infeasible from current worker counts, or
// ordering by observed cluster state -- is explicitly future work, not v1;
// ordering MAY consult the full
// primary_resreq already passed to init() (span/same/affinity) without an
// interface change, matching AC1's existing forward-compatibility note.
// Deferred to a later enhancement, not this spec: a formal pluggable-strategy
// API for swapping either policy independently. v1 hardcodes both inside the
// combinatorial plan iterator; only allocator swap-out (Req 2 AC1's existing
// "future allocators bring their own iterator" path) is required now.

xce_host_iter_t (new, Req 3)

typedef struct xce_host_iter_s xce_host_iter_t;

// Correction (found during task 1.1 implementation): next() returns
// xce_sched_worker_t* (sched/types.h -- the scheduler-internal type
// carrying resvals/resInUse/host_eval_ctx), NOT xce_worker_t* (xce/types.h
// -- the wire/config type make_sched_worker() converts FROM). This
// Composition Algorithm's own pseudocode already uses
// `xce_sched_worker_t* w = host_iter->next(host_iter)` -- this struct
// declaration was simply out of sync with it. sched_check_host_satisfy()/
// sched_meet_host_selection() (resource.c), which any real host_iter
// implementation must call, both take xce_sched_worker_t*, not
// xce_worker_t*.
struct xce_host_iter_s {
    // Initialize the iterator for a given plan and worker set.
    // Returns 0 on success, nonzero on error.
    int  (*init)(xce_host_iter_t* iter, xce_alloc_plan_t* plan,
                 var workers);

    // Return the next candidate worker matching the plan's host filter,
    // or NULL when exhausted. The returned worker pointer is valid until
    // the next next() or fini() call.
    xce_sched_worker_t* (*next)(xce_host_iter_t* iter);

    // Release all resources held by the iterator.
    void (*fini)(xce_host_iter_t* iter);

    // Private state: current position, filtering state
    void* private;
};

New types (Req 7)

// Resreq portion of the cache key — pre-computed, shareable across jobs
// with identical resreqs. Fits in a single cache line (24 bytes).
typedef struct {
    int      min_tasks;
    int      max_tasks;
    uint32_t select_node_id;   // CSE-interned root; 0 if select is NULL
    uint64_t rusage_hash;      // computed at parse time, stable for life of resreq
} xce_resreq_fingerprint_t;

// Full cache key — resreq fingerprint + identity.
// Identity fields are xstring pointers (not owned, not freed).
// Pointer equality = content equality for xstring-interned strings.
typedef struct {
    xce_resreq_fingerprint_t resreq;
    const char*              user;     // xstring pointer
    const char*              queue;    // xstring pointer
    const char*              project;  // xstring pointer
} xce_cache_key_t;

// Correction (this review): this codebase's real kvtab API (kv.h/kv.c) does
// NOT take a hash/eq pair per lookup call -- a kvtab_t binds ONE fixed
// kv_key_ops_t (.hash/.assign/.cmp/.destroy) at table-CREATION time
// (kv_params_t), and every lookup/insert goes through the generic
// key_find()/key_add()/key_del() using whatever keyops that table was built
// with. int_key_ops/string_key_ops are the only existing kv_key_ops_t
// instances -- a composite struct key needs a THIRD one, new to this spec:
static uint32_t cache_key_hash_(void* k) {
    const xce_cache_key_t* key = (const xce_cache_key_t*)k;
    uint32_t                h  = 2166136261u;  /* FNV-1a 32-bit -- no
                                                  * existing hash-update
                                                  * helper in this codebase;
                                                  * this is new, self-
                                                  * contained code. */
    const unsigned char* p = (const unsigned char*)&key->resreq;
    for (size_t i = 0; i < sizeof(key->resreq); i++) {
        h = (h ^ p[i]) * 16777619u;
    }
    h = (h ^ (uint32_t)(uintptr_t)key->user)    * 16777619u;
    h = (h ^ (uint32_t)(uintptr_t)key->queue)   * 16777619u;
    h = (h ^ (uint32_t)(uintptr_t)key->project) * 16777619u;
    return h;
}

// cmp -- three-way comparator, 0 == equal (confirmed against kv.c's real
// int_key_ops.cmp -> cmp_int_key: returns -1/0/1, and kvtab_get() checks
// `cmp(...) == 0` for a match). Correction (design-phase discovery): an
// earlier version of this function returned a BOOLEAN (1 for match, via
// &&-chained == comparisons) -- exactly inverted from the real contract.
// As originally written, every lookup would have been a false miss,
// silently disabling the entire cache (Req 7) without failing any test
// that didn't specifically assert a hit. memcmp() on the resreq portion
// already returns a 3-way result; the identity pointers just need the
// same shape.
static int cache_key_cmp_(void* a, void* b) {
    const xce_cache_key_t* ka = (const xce_cache_key_t*)a;
    const xce_cache_key_t* kb = (const xce_cache_key_t*)b;
    int rc = memcmp(&ka->resreq, &kb->resreq, sizeof(ka->resreq));
    if (rc != 0) {
        return rc;
    }
    if (ka->user != kb->user) {
        return (ka->user < kb->user) ? -1 : 1;
    }
    if (ka->queue != kb->queue) {
        return (ka->queue < kb->queue) ? -1 : 1;
    }
    if (ka->project != kb->project) {
        return (ka->project < kb->project) ? -1 : 1;
    }
    return 0;
}

// assign -- kvtab keys are heap-owned by the table (see string_key_ops'
// strdup-on-insert precedent); *dest must be a fresh heap copy of the
// stack-constructed key passed at the lookup site, not an alias of it.
static int cache_key_assign_(void** dest, void* src) {
    xce_cache_key_t* copy = (xce_cache_key_t*)malloc(sizeof(xce_cache_key_t));
    if (copy == NULL) {
        return -ENOMEM;
    }
    memcpy(copy, src, sizeof(xce_cache_key_t));
    *dest = copy;
    return 0;
}

// destroy -- frees only the key struct itself; user/queue/project are
// borrowed xstring pointers (not owned, not freed here).
static void cache_key_destroy_(void* k) {
    free(k);
}

static kv_key_ops_t cache_key_ops = {
    .hash    = cache_key_hash_,
    .assign  = cache_key_assign_,
    .cmp     = cache_key_cmp_,
    .destroy = cache_key_destroy_,
};

// Table construction: standard kvtab_type RTTI pattern (see sched->core.plugins/
// sched->core.resmgr for a live, already-working example of this exact
// construction call site in this codebase) with
// kv_params_t{ .nslots = N, .keyops = &cache_key_ops, .datakiller = NULL }
// (NULL datakiller: a cache "value" is a hit/miss sentinel, nothing to free).

xce_primary_resreq_t addition (Req 7)

One new field, set once at parse time:

// In xce_primary_resreq_t (resreq.h):
uint64_t rusage_hash;  // set by parse_resreq_primary(), stable for life of resreq

xce_sched_obj_t / xce_primary_schedobj_t addition (Req 7)

Store the resreq portion of the fingerprint on the scheduling object so it is computed once and reused for every cache lookup of that job:

// On xce_primary_schedobj_t (or xce_sched_obj_t):
xce_resreq_fingerprint_t resreq_fp;  // populated on job ingest, read by cache lookup

Lookup site (pseudocode)

// Before allocation: construct key on stack, single hash + lookup via the
// real kvtab API (key_find/key_add -- NOT a free-function kvtab_lookup()
// taking hash/eq params, which doesn't exist in this codebase).
xce_cache_key_t key = {
    .resreq  = obj->u.primary->resreq_fp,
    .user    = obj->user,
    .queue   = obj->queue,
    .project = obj->project,
};
void* unused = NULL;
if (key_find(sched->unsatisfiable_cache, &key, &unused) > 0) {
    pend_job(obj, original_reason);
    return;
}
// on exhaustion (Req 7 AC 3): key_add(sched->unsatisfiable_cache, &key, SENTINEL)

Key design decisions

  • struct-based: hash/cmp/assign/destroy live together in one kv_key_ops_t, matching this codebase's existing int_key_ops/string_key_ops idiom rather than an ad-hoc per-call hash/eq pair.
  • memcmp on resreq portion: all fields are fixed-size integers/IDs — no pointers, no deep compare.
  • pointer equality on identity: xstring guarantees identical content → identical pointer.
  • select via node_id: CSE intern table deduplicates identical select expressions. O(1).
  • rusages via pre-computed hash: set once at parse time, stable for life of resreq. O(1).
  • span/same/affinity deferred: currently empty structs; add to xce_resreq_fingerprint_t when they gain fields.

xce_sched_ctx_t addition (allocation mode, Req 9 AC 5)

A mode field that controls which resource pool the allocator draws from. Defaults to XCE_ALLOC_NORMAL. Consumed by try_alloc_on_host() and any resource-availability check — applies to all resource types (slots, shared resources, limit instances, licenses, etc.), not just CPU slots.

// In xce_sched_ctx_t (sched/types.h):
typedef enum {
    XCE_ALLOC_NORMAL  = 0,  // only truly free resources
    XCE_ALLOC_PREEMPT,       // count preemptible job resources as available
    // Future:
    // XCE_ALLOC_RESERVE,    // count reserved-but-idle resources as available
    // XCE_ALLOC_BACKFILL,   // count reserved + preemptible resources
} xce_alloc_mode_t;

xce_alloc_mode_t alloc_mode;  // XCE_ALLOC_NORMAL by default

When alloc_mode != XCE_ALLOC_NORMAL, resource-availability checks count the full resource footprint of victim-candidate jobs (not just slots) as borrowable. The allocator logic is otherwise identical — no fork in try_alloc_on_host().

xce_preempt_victim_iter_t (new, scheduler-preemption spec)

Same lazy-iterator pattern as plan iterator, applied to victim selection:

typedef struct xce_preempt_victim_iter_s xce_preempt_victim_iter_t;

struct xce_preempt_victim_iter_s {
    int  (*init)(xce_preempt_victim_iter_t*, xce_sched_job_t* job, xce_scheduler_t*);
    xce_preempt_victim_set_t* (*next)(xce_preempt_victim_iter_t*);
    void (*fini)(xce_preempt_victim_iter_t*);
    void* private;
};

The default policy (lowest_priority_first) generates victim sets lazily: smallest sufficient set of lowest-priority running jobs from preemptible_queues that would free enough resources. Listed here because the preemption flow wraps allocate_primary() and consumes xce_alloc_mode_t — both defined in this spec.

parjob_allocator changes (Req 4)

  • my_alloc() is replaced from a stub (return 0) to a real implementation that creates a plan iterator, walks candidate plans, and commits on first success.
  • Correction (this review): "only job_alloc_fn changes" is wrong -- an earlier pass of this document said so before Req 4 AC 6 was added; parjob_prepare_allocation() (the plugin's separate prepare_allocation_hook) also changes, per the next bullet. What IS unchanged is the plugin's registration struct itself (name/init_fn/fini_fn/job_elect_fn/data, register_parjob_allocator()) -- only the bodies of its two hook implementations change.
  • parjob_prepare_allocation() (currently also a stub -- see Req 4 AC 6) must be fixed alongside my_alloc(): it is the hook that populates ctx->alloc_req.host/ctx->alloc_req.shared (via calc_wanted_host_resvals/calc_wanted_global_resvals, the same calls seqjob_prepare_allocation() already makes) for whichever bucket is currently being tried. xce_sched_ctx_t already carries nhosts/nslots fields for exactly this; the per-bucket loop sets ctx->nslots = bucket_slots[b] (ctx->nhosts = 1) before each host try, and the fixed hook reads them instead of the hardcoded 1, 1 seqjob's version uses. Because ctx->alloc_req is a single scratch buffer reset and repopulated per candidate host (plugin_prepare_allocation(), plugin.c:591, xvec_idval_fini+inits it before each fan-out), this is safe to recompute per host-try -- see Req 4 AC 6 for the corollary seqjob_prepare_allocation() gate this depends on.
  • Do not bypass plugin_prepare_allocation()'s fan-out to precompute a bucket's resource want once and reuse it across every candidate host. The rusage-derived portion (calc_wanted_host_resvals) is host-independent and in principle cacheable per bucket, but the same function's tail call to sched_fill_limit_requests(sched, &ctx->alloc_req.host, hlimits) reads hlimits = zarray_get(&obj->hlimits, ctx->worker->idx) -- genuinely host-specific per-host limit-tracking state. A full per-bucket bypass would silently stop enforcing per-host limits for jobs allocated through the new iterator path. Caching only the rusage-derived slice (still running the limit-fill per host) is a valid future optimization but is not required for this spec.

Final allocation result -- merged from each claimed bucket, no new type needed. xce_job_alloc_t (xce/types.h) already generalizes past one host: nhosts + xce_host_alloc_t** allocs. Today only seqjob_allocator exercises it, always with nhosts=1 via the variadic make_host_allocs(1, halloc). For a successful multi-bucket plan, commit_plan_allocation() builds one xce_host_alloc_t per claimed host (each from that bucket's own snapshotted alloc_req.host/uncomit, captured at the moment try_alloc_on_host succeeded for it -- alloc_req.host is overwritten on the next bucket's prepare/check cycle, so it cannot be read lazily after the loop moves on), collects them into an xce_host_alloc_t** hallocs array of length nbuckets (built directly with calloc+loop, since make_host_allocs()'s variadic signature only fits a compile-time-fixed argument list, not a runtime-sized claimed set), and calls the existing make_job_alloc(jobid, nbuckets, hallocs) unchanged. Shared-resource allocation (allocs_shared/nres_shared) stays job-level and computed once for the whole primary resreq, exactly as it is today -- it does not vary per bucket and is not part of the merge.

seqjob_allocator changes (Req 9 AC 1)

  • Internal rewrap: my_alloc() is restructured to use the plan and host iterator interfaces internally as a trivial case (1 host x 1 slot plan, then host iteration matching today's sched_collect_workers() + xlist_find() logic).
  • Externally observable behavior is unchanged -- same host selection given same state.
  • seqjob_prepare_allocation() gains a primary->maxTasks == 1 guard (Req 4 AC 6). This is a no-op for seqjob's own observable behavior -- it never handles maxTasks > 1 jobs regardless -- but is required for parjob_allocator correctness once its own prepare hook stops being a stub; see Req 4 AC 6 for why the unguarded fan-out is unsafe.

Architecture Diagram

Allocation Flow (PRIMARY leaf)

┌──────────────────────────────────────────────────────────┐
│              ALLOCATE PRIMARY (Req 2+3+4)                  │
│                                                            │
│  plan_iter_init(primary_resreq)                            │
│       │                                                    │
│       ▼                                                    │
│  ┌──────────────────────┐                                  │
│  │ plan_iter->next()     │  ←─ generates per-host bucket   │
│  │ returns plan or NULL  │    partitions (e.g. [2,1])      │
│  └──────────┬───────────┘    most-preferred first          │
│             │                                              │
│       plan  │   NULL (exhausted)                           │
│             ▼              ▼                               │
│  ┌──────────────────┐   return ALLOC_FAIL                  │
│  │ host_iter_init()  │                                      │
│  └────────┬─────────┘                                      │
│           │                                                │
│           ▼                                                │
│  ┌──────────────────────┐                                  │
│  │ host_iter->next()     │  ←─ filters workers by          │
│  │ returns worker or NULL│    select expression +          │
│  └──────────┬───────────┘    slot availability             │
│             │                                              │
│     worker  │   NULL (no host for plan)                    │
│             ▼              ▼                               │
│  ┌──────────────────┐   host_iter->fini()                  │
│  │ try_alloc(worker, │   plan_iter->next()  (next plan)    │
│  │   plan, ctx)      │                                      │
│  └────────┬─────────┘                                      │
│           │                                                │
│   success │ fail                                           │
│           ▼        ▼                                       │
│  ┌────────────┐  host_iter->next() (next worker for plan)  │
│  │ commit      │                                            │
│  │ return      │                                            │
│  │ ALLOC_SUCCESS│                                           │
│  └────────────┘                                            │
└──────────────────────────────────────────────────────────┘

Simplified -- see "Composition Algorithm" above for the authoritative version. This diagram shows one worker being tried per plan, which is exactly right for seqjob_allocator (nbuckets == 1) but is NOT the full picture for parjob_allocator's multi-bucket plans: a real plan with nbuckets > 1 requires an inner loop over each bucket, accumulating a distinct claimed host per bucket before the plan counts as satisfied (host-exclusivity, per-bucket resource snapshotting, and rollback-on-partial-failure all live in that inner loop, none of which fits cleanly into this box diagram). Treat this diagram as the single-bucket special case; the pseudocode under "Composition Algorithm" is the one to implement against.

Unsatisfiable Resreq Cache (Req 7)

┌──────────────────────────────────────────────────────────┐
│              SESSION START (begin_session)                 │
│                                                            │
│  Clear unsatisfiable resreq fingerprint cache              │
│                                                            │
│  For each job from picker:                                │
│       │                                                    │
│       ▼                                                    │
│  ┌──────────────────────────────┐                         │
│  │ Lookup fingerprint in cache   │                         │
│  │ key = hash(minTasks,          │                         │
│  │   maxTasks, select_node_id,   │                         │
│  │   rusage_hash, user, queue,   │                         │
│  │   project)                    │                         │
│  └──────────┬───────────────────┘                         │
│             │                                              │
│     hit     │   miss                                       │
│      ▼      │     ▼                                        │
│  ┌────────┐ │  ┌──────────────────┐                       │
│  │ PEND    │ │  │ Run full          │                       │
│  │ immedi- │ │  │ allocation        │                       │
│  │ ately   │ │  │ (Req 2+3+4)       │                       │
│  └────────┘ │  └────────┬─────────┘                       │
│             │           │                                  │
│             │   success │ fail (exhausted)                 │
│             │           ▼        ▼                         │
│             │  ┌────────────┐ ┌──────────────────────┐    │
│             │  │ DISPATCH    │ │ Populate cache with   │    │
│             │  │ (no cache   │ │ resreq fingerprint    │    │
│             │  │  entry)     │ │ then PEND job         │    │
│             │  └────────────┘ └──────────────────────┘    │
└──────────────────────────────────────────────────────────┘

Integration Note: Preemption Interaction

Preemption (scheduler-preemption spec) fires when allocation fails for a preemptible job. It follows the same lazy-iterator pattern as allocation, applied to victim selection instead of host selection.

Preemption Iterator

Same shape as plan iterator, different domain:

// Preemption victim iterator — lazy generation of victim sets
typedef struct xce_preempt_victim_iter_s xce_preempt_victim_iter_t;

struct xce_preempt_victim_iter_s {
    int  (*init)(xce_preempt_victim_iter_t* iter, xce_sched_job_t* job,
                 xce_scheduler_t* sched);
    xce_preempt_victim_set_t* (*next)(xce_preempt_victim_iter_t* iter);
    void (*fini)(xce_preempt_victim_iter_t* iter);
    void* private;
};

The default policy (lowest_priority_first) generates victim sets in order: smallest sufficient set of lowest-priority running jobs from preemptible_queues that would free enough resources for the preempting job.

Allocation Mode

A xce_alloc_mode_t enum on xce_sched_ctx_t controls which resource pool the allocator draws from — applies to all resource types (slots, shared resources, limit instances, licenses), not just CPU slots:

typedef enum {
    XCE_ALLOC_NORMAL  = 0,  // only truly free resources
    XCE_ALLOC_PREEMPT,       // count preemptible job resources as available
} xce_alloc_mode_t;

When alloc_mode != XCE_ALLOC_NORMAL, resource-availability checks count the full resource footprint of victim-candidate jobs as borrowable. The allocator logic is otherwise identical — no fork in try_alloc_on_host().

Full Preemption Flow

Cache placement, corrected: the cache is keyed by resreq_fp, which lives on xce_primary_schedobj_t -- a PRIMARY-level concept, not a whole-job one (a COMPOUND/ALTER job has no single resreq shape of its own to fingerprint). So the cache check wraps allocate_primary() itself, not allocate_job()'s top-level dispatch. For a bare PRIMARY job this is the whole story; for a COMPOUND or ALTER job, each nested primary gets its own cache check for free, once for every call allocate_compound()/allocate_alter() already makes into allocate_primary() -- no job-level cache entry is needed or possible.

// Wraps allocate_primary() -- called for EVERY primary reached, whether the
// job's own resreq IS a primary, or this is one primary nested inside a
// COMPOUND (allocate_compound()'s loop) or an ALTER's compound (transitively).
int allocate_primary_with_preempt(sched, primary_resreq, ctx, job) {
    // 1. Cache check (Req 7) -- keyed on THIS primary's resreq_fp, not job-level
    if (cache_lookup(primary_resreq, job)) return PEND;

    // 2. Normal allocation
    if (allocate_primary(sched, primary_resreq, ctx) > 0) return ALLOC_SUCCESS;

    // 3. Preemption (if applicable)
    if (job->queue->preemptible_queues == NULL) {
        cache_insert(primary_resreq, job);
        return PEND;
    }

    victim_iter_init(&viter, job, sched);
    while ((victims = viter.next(&viter)) != NULL) {
        ctx.alloc_mode = XCE_ALLOC_PREEMPT;
        if (allocate_primary(sched, primary_resreq, &ctx) > 0) {
            viter.fini(&viter);
            return commit_preempt(victims);
        }
        ctx.alloc_mode = XCE_ALLOC_NORMAL;
    }
    viter.fini(&viter);

    cache_insert(primary_resreq, job);
    return PEND;
}

Key properties:

  • v1 cache is preemption-agnostic (populated on allocation failure alone).
  • When preemption ships, it fires between allocation failure and cache population.
  • If preemption succeeds → no cache entry, preempting job is re-pended for next round.
  • If preemption fails/exhausted → cache entry populated, job pended normally.
  • Plan and host iterators are not preemption-aware — they simply report success/failure.
  • xce_alloc_mode_t alloc_mode is the only new allocator surface needed.

Implementation Order

Scheduler sub-specs are designed for sequential delivery:

Phase 1: scheduler-allocation     (this spec)
  ├── plan_iter + host_iter       foundation iterators
  ├── seqjob rewrap               validates abstraction on simple case
  ├── parjob combinatorial        real parallel allocation
  └── resreq cache                performance optimization

Phase 2: scheduler-core
  ├── core/driver split           extracts xce_sched_core_t from xce_scheduler_t
  ├── picker interface            xce_job_picker_iface_t + builtin_fcfs
  └── session loop wires          core_go() calls allocate_job()

Phase 3: scheduler-preemption
  ├── victim_iter                 lazy victim-set generator
  ├── XCE_ALLOC_PREEMPT mode      tells allocator to count borrowed resources
  └── preempt_policy plugin       lowest_priority_first default
Phase Why there
Allocation first Self-contained. Unit-testable with fake workers. No core, no preemption needed. Iterators are the building block everything else sits on.
Core second Wires allocation into a real scheduling loop. Picker feeds jobs → allocator places them → decisions flow. Without allocation, core has nothing to call.
Preemption last Wraps allocation. Needs allocate_primary() to exist. Needs core session loop to re-pend preempted job. Is a "retry with borrowing" layer on top of normal allocation.