myxce/.kiro/specs/scheduler-allocator-runner/brief.md
Rongsong Shen b4470b2b94
test(scheduler-allocator-runner): characterize the parjob allocator (task 2.2)
15 tests drive the real parjob plugin through job_alloc_fn: success,
short shared, pending on hosts, mid-plan rollback, task counts 0/1,
missing inputs, six OOM steps with their debits, COMPOUND. Values the
runner changes on purpose sit in *_today tests.

Spec: the task showed that parjob records host reasons only for a bucket
search's first rejected candidate (the rejected host's request stays in
alloc_req.host and the host iterator pre-filters later hosts against
it). Decision: the runner clears alloc_req.host before every candidate,
for both allocators, so every short host gets its reasons (Req 3.6/3.7);
publishing reasons only when they change is a follow-up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 08:40:35 +00:00

11 KiB

Brief: scheduler-allocator-runner

Problem

Scheduler plugin authors and maintainers. seqjob_allocator.c (my_alloc, line 213, ~215 lines) and parjob_allocator.c (my_alloc, line 545, ~220 lines) each own the entire allocation algorithm, although only plan generation is plugin-specific. ~100+ lines are duplicated verbatim (lazy pend-reason, sched_ctx_get_uncomit_shared, sched_check_shared_satisfy + PREASON_NO_RESOURCE fill, add-uncommitted-shared, plan-iterator loop skeleton, commit tail with sched_make_allocs + -ENOMEM cleanup chain). Every bug fix (bug-1882, bug-1767, bug-1768) has to land twice, and a new allocator plugin must copy the whole my_alloc(). Driver: issues/new_requirements.md item 4.

Current State

  • Framework dispatch sched_make_primary_alloc_() (scheduler.c:235) picks core->seqjob_allocator (maxTasks == 1) or core->parjob_allocator and calls plugin->job_alloc_fn(plugin, ctx).
  • Plan generation is already abstracted behind xce_alloc_plan_iter_t (include/sched/alloc_iter.h); host scan behind xce_host_iter_t.
  • The picker side already uses a framework-owned iface pattern (xce_job_picker_iface_t, plugin->picker_iface, plugin.h:88).
  • alloc_iter.h init's third parameter is xce_scheduler_t*, unused by both implementations ((void)sched); sim mode has no real scheduler.
  • parjob has parjob_try_plan_ / parjob_rollback_claims_ / parjob_snapshot_host_req_ claim bookkeeping; seqjob keeps a single w.

Desired Outcome

  • Neither plugin defines my_alloc(); both register .job_alloc_fn = sched_allocator_run and supply only an xce_sched_allocator_iface_t (optional accepts guard + plan iterator) plus their existing prepare_allocation.
  • One framework function owns shared-resource prep, the bucket -> host-claim loop with rollback, and the commit tail.
  • Allocation decisions are bit-for-bit identical to today for every existing test and sim-parity fixture. Intentional differences, all outside the decision and main pend reason: pending seqjob jobs may gain per-host NO_RESOURCE reasons (Constraints item 1); spurious shared reasons are recorded for the first time (bug-1893, item 6); seqjob gets the -EINVAL guard and OOM rollback (Req 5 AC 1/5).
  • new_requirements.md item 4 closed.

Approach

Framework runner, lean (chosen 2026-09-28). The design proposal of 2026-09-23 (§5), with the optional commit hook left out because nothing uses it yet:

  • xce_sched_allocator_iface_t { accepts; plan_init; plan_next; plan_fini; } in plugin.h; allocator_iface pointer on xce_sched_plugin_t with the same ownership as picker_iface.
  • sched_allocator_run() = guards -> accepts -> sched_alloc_prepare_shared_() -> plan loop -> sched_try_plan_() (promoted parjob_try_plan_) -> sched_finalize_job_alloc_() (one make_host_alloc per claim -> make_job_alloc -> allocs_shared block).
  • Retype alloc_iter.h init's third parameter to xce_sched_core_t*.
  • Staged migration keeps the suite green: (1) extract shared helpers used by both existing my_alloc()s; (2) runner + iface, parjob first; (3) seqjob; (4) retype plan_init; (5) close item 4.

Rejected: adding the commit override hook (an unused extension point that would need its own tests); only extracting helpers (would not meet item 4's goal that plugins no longer export my_alloc()).

Scope

  • In: new allocator iface + framework runner; shared prep/try-plan/ finalize helpers; delete both my_alloc()s and parjob's claim helpers; retype xce_alloc_plan_iter_t.init to xce_sched_core_t*; equivalence tests; the bug-1893 loop-bound fix (Constraints item 6); update issues/new_requirements.md item 4.
  • Out: see Out of Boundary.

Boundary Candidates

  • Plugin ABI (plugin.h iface + plugin template wiring)
  • Framework allocation runner (new plugin_alloc.c or scheduler.c) — shared-resource prep, claim loop/rollback, commit tail
  • Plan-iterator contract (alloc_iter.h retype; seqjob/parjob plan iters)

Out of Boundary

  • Retyping sched_make_allocs() to xce_sched_core_t* and removing the (xce_scheduler_t*)plugin->sched cast (follow-up; callers outside allocators).
  • Collapsing sched_make_seqjob_allocation / sched_make_parjob_allocation dispatch in scheduler.c (optional, only if trivially falls out).
  • The commit override hook.
  • bug-1892 (unsatisfy_() reads the index-keyed uncommitted vector by list position; wrong admission for later primaries of COMPOUND/ALTER jobs). It lives in resource.c's satisfy checks, which the runner calls unchanged; fix it separately. Equivalence tests for COMPOUND/ALTER must not encode its wrong outcomes as expected values.
  • The FIXME: no duration now (NULL duration to sched_make_allocs): carry it over unchanged, don't fix it here.
  • Any change to plan ordering, host ordering, pend-reason content, or preemption, except the decided seqjob host-reason change in Constraints item 1.

Upstream / Downstream

  • Upstream: scheduler-allocation (plan/host iterators, Req 4 combinatorial parjob, Req 9 no-regression); scheduler-core (plugin/picker iface pattern, sim mode); libxce-alloc-preason-equality (job_alloc_eq used by sim verify).
  • Downstream: any future allocator plugin (it would need only a plan iterator); scheduler-preemption; the follow-up sched_make_allocs retype.

Existing Spec Touchpoints

  • Extends: none (scheduler-allocation is implementation-complete; its Req 9 is inherited as a constraint, not reopened).
  • Adjacent: scheduler-allocation, scheduler-core, scheduler-sim-discrete-clock (in flight, touches sim_loop — avoid overlapping files), libxce-alloc-preason-equality.

Constraints

  • TDD: tests in src/scheduler/testsuite/ first; full ASAN build via /xce-build scheduler.
  • Behavioral equivalence is mandatory. Preserve exactly: bug-1882 (half-built alloc never left on ctx->alloc), bug-1767 (parjob maxTasks <= 1 returns 0 with no fabricated reason, and before the lazy reason is created), bug-1768; -ENOMEM cleanup order of both current tails, plus the seqjob OOM rollback of item 2 (Req 5 AC 5).
  • Divergences found in discovery that the design MUST resolve explicitly (they are not in the 2026-09-23 proposal):
    1. Decided 2026-09-29, revised 2026-09-30 (task 2.2 finding, user decision): the runner clears ctx->alloc_req.host before every candidate host (at the start of each bucket and after every rejected candidate), for both allocators. The per-host request used by the authoritative check is rebuilt for every candidate anyway (plugin_prepare_allocation() with ctx->worker set, plugin.c:639). What the clear changes is the host iterator's pre-filter (host_iter.c:143), which reads alloc_req.host before that rebuild and silently skips hosts that fail. The 2026-09-29 plan (clear once per bucket, as parjob does) turned out not to be enough: task 2.2's characterization showed that a rejected candidate leaves its own request behind, so parjob today records reasons only for the first rejected candidate of a bucket search, and a once-per-bucket clear would give seqjob the same partial reasons. With a clear before every candidate, the pre-filter only applies the select expression, and every selection-matching unclaimed short host gets its host/resource reasons, in both allocators. Accepted consequences (intentional changes): host choice, main reason and decisions are unchanged (a host the stale pre-filter rejects also fails the authoritative check while the per-host request is host-independent; where a limit makes it host-specific, the stale filter could wrongly skip a host, which the clear fixes). Pending jobs of both allocators carry more host/resource reasons (res_based_reasons; not main_reason, so the unsatisfiable cache and sim parity are unaffected). The per-host prepare fan-out, including the limit plugin's lazy host-limit instantiation, now runs on every short host (decision-neutral; one prepare + check per short host per bucket search). Larger reason sets are published and persisted; publishing reasons only when they change is a follow-up (user, 2026-09-30), out of scope here. Rejected: clearing once per bucket (partial reasons, still stale-state dependent); keeping today's per-plugin behaviour.
    2. Decided (requirements Req 5 AC 5): seqjob builds its halloc directly from ctx->alloc_req.host; under the shared per-claim snapshot it gains a new -ENOMEM path, which goes through the same rollback (sched_ctx_uncommit_host) as parjob's.
    3. Decided 2026-09-29: for seqjob, ctx->nslots and ctx->nhosts are always 1. Their only consumer is sched_fill_limit_requests() in plugin_prepare_allocation() (plugin.c:682 job-level pass, :699 per-host pass); sched_try_alloc_on_host_() does not read them. Today seqjob sets 1/1 only in its job-level prepare pass (seqjob_allocator.c:92) and relies on the values carrying into the per-host passes. The shared loop sets ctx->nslots = plan->bucket_slots[b] and ctx->nhosts = 1 before every bucket's host scan (as parjob does, parjob_allocator.c:492); seqjob's plan is always one bucket of one slot (seqjob_allocator.c:163), so seqjob sees exactly 1/1 in every pass. The seqjob plan iterator must keep nbuckets == 1 and bucket_slots[0] == 1 (assert in tests).
    4. Decided (requirements Req 5 AC 3): seqjob's accepts is NULL. seqjob has no maxTasks guard today, and a seqjob_accepts (maxTasks == 1) would change behaviour for direct callers with another value (48 testsuite files reference the allocators).
    5. Proposal-sketch defects to avoid: sched_ctx_primary == NULL typo; claims[plan->nbuckets] VLA (heap-allocate or bound by maxTasks).
    6. Decided 2026-09-29 (requirements review), corrected 2026-09-30 (task 2.1): the shared pend-reason loop iterates the unmet list (ctx->unmeet.count) and skips only on vget() < 0, fixing bug-1893. Both current my_alloc()s skip on vget() <= 0, but vget returns 0 on success, so today no shared NO_RESOURCE reason is ever recorded (the loop also runs to alloc_req.shared.count). After the fix a job pending on shared resources names each short one; main reason and decisions are unaffected.
  • Claim host_req snapshots are owned by the framework: fini on every exit path (success, bucket failure, OOM).
  • Verify with: test_seqjob_*, test_parjob_*, test_seqjob_rewrap_equivalence, test_maxtasks_wiring_*, test_sim_*_parity, test_sim_live_allocation, test_snapshot_load_allocator_regression; /xce-audit-invariants scheduler (driver state-equivalence).