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>
186 lines
11 KiB
Markdown
186 lines
11 KiB
Markdown
# 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).
|