xce_sched_allocator_iface_t (optional accepts guard + plan_init/next/fini) and the sched_allocator_run() entry point in sched/alloc_iter.h; the plugin's const allocator_iface reference, forward-declared in plugin.h with the picker_iface ownership rule. No plugin uses them yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
Research & Design Decisions
Summary
- Feature:
scheduler-allocator-runner - Discovery Scope: Extension (light discovery). Refactor of an existing plugin seam inside
src/scheduler; no new external dependency. - Key Findings:
- The two
my_alloc()s differ only in: the plan iterator they wire, parjob'smaxTasks <= 1guard and NULL-input guard, parjob's per-bucketalloc_req.hostreset, per-candidatenslots/nhostsassignment, and claim bookkeeping (parjob snapshots each claim'shost_req; seqjob keeps a singlewand reads the livealloc_req.hostat commit). Everything else is duplicated text. xce_alloc_plan_tis an anonymous typedef declared inalloc_iter.h, which itself includesscheduler.h→plugin.h, so the full allocator iface cannot live inplugin.hwithout either a header cycle or a tag change. The picker iface already solves the same problem with a forward typedef inplugin.h.- Three white-box tests call the static
my_alloc()/parjob_try_plan_()/parjob_rollback_claims_()directly and stubsched_make_allocs()(bug-1882 OOM injection); none asserts pend-reason content.test_parjob_combinatorial.cpassesNULLand a poisonedxce_scheduler_t*as the plan iterator's third argument.
- The two
Research Log
Current allocator code paths
- Context: Establish exactly what the runner must reproduce (Req 3, 4, 5).
- Sources Consulted:
src/scheduler/src/plugins/seqjob_allocator.c:213-420,parjob_allocator.c:300-760,src/scheduler/src/resource.c:493-620,src/scheduler/src/plugin.c:612-705,src/scheduler/src/host_iter.c:100-170,src/scheduler/src/scheduler.c:235-435. - Findings:
- Shared prep is byte-identical in both: lazy
make_pendreason(0)→sched_ctx_get_uncomit_shared→sched_check_shared_satisfy→NO_RESOURCEfill →xvec_idval(uncomit, &shared, XVEC_ADD_FLOAT). The fill loop is bounded byalloc_req.shared.countoverctx->unmeet(bug-1893). - Plan-init failure returns 0 in both (no
fini). - parjob:
claimed[primary->maxTasks]VLA; per bucket: resetalloc_req.host, host iter, skip claimed, setnslots = bucket_slots[b],nhosts = 1,sched_try_alloc_on_host_() > 0→ snapshotalloc_req.host(OOM → uncommit candidate with live request + rollback earlier claims → return rc). - Commit tail: parjob
callocofhallocs, per claimsched_make_allocs(sched, "", nres, idxs, vals, NULL)+make_host_alloc; failure rolls back claims[i, n)and freeshallocs; thenmake_job_alloc(0, n, hallocs); then the shared-record block. seqjob usesmake_host_allocs(1, halloc), which produces the same one-element array. - Both set
ctx->allocbefore building the shared records andfree_JobAlloc_ptr(&ctx->alloc)(which NULLs it) on failure.
- Shared prep is byte-identical in both: lazy
- Implications: The runner is parjob's algorithm, generalised, plus the shared-prep block; seqjob becomes a degenerate case (1 plan, 1 bucket of 1 slot).
Where nslots/nhosts are consumed
- Context: brief Constraints item 3.
- Findings: Only
sched_fill_limit_requests()reads them (plugin.c:682job-level pass,:699per-host pass). seqjob sets 1/1 only in its job-level pass (seqjob_allocator.c:92). - Implications: The runner sets both per candidate from the plan (Req 3.5); seqjob sees 1/1 everywhere.
The per-host request and the host iterator pre-filter
- Context: brief Constraints item 1, Req 3.6/3.7.
- Findings:
plugin_prepare_allocation()withctx->workerset clears and refillsalloc_req.hostper candidate (plugin.c:639), so the authoritative check always uses a fresh request.sched_host_iter_next()pre-filters against whateveralloc_req.hostholds before that refill and skips failures silently.add_job_preason(..., host, res)writesres_based_reasons, nevermain_reason(lib/preason.c:147); the unsatisfiable cache readsmain_reasononly (scheduler.c:271); sim parity comparesmain_reasononly (sim_verify.c:126). - Implications: The unconditional per-bucket reset changes only seqjob's host/resource reasons.
Plugin ABI and header layout
- Context: Req 1.1–1.6, 6.1.
- Sources Consulted:
include/plugin.h,include/sched/alloc_iter.h,include/scheduler.h,include/core.h. - Findings:
plugin.hincludes onlystdint.h,stdlib.h,decision.h,resreq.handsched/types.h(corrected 2026-09-30 during task 1.2: discovery misread a truncated listing as "no includes");core.handscheduler.hinclude it.alloc_iter.hincludesscheduler.h.picker_ifaceis a forward-declaredxce_job_picker_iface_t*pointing at static storage that the registry never frees (plugin.c:111,149). Plugin templates use designated initialisers and arememcpy'd at registration. - Implications: Forward typedef in
plugin.h; full struct and runner prototype inalloc_iter.h; iface storagestatic constin each plugin file.
Simulation mode
- Context: Req 5.1, 6.3.
- Sources Consulted:
src/scheduler/src/sim_alloc.c. - Findings: sim registers the same plugins through
seqjob_allocator_new_plugin()/parjob_allocator_new_plugin()againstsim->coreand setsplugin->schedto a stand-in wrapper used only bysched_make_allocs(). Soplugin->schedis non-NULL in both modes. - Implications: No sim code change beyond comments; the runner reaches sim through the plugin templates.
Build and test wiring
- Sources Consulted:
src/scheduler/meson.build:25-45,testsuite/meson.build:1700-1760,testsuite/test_picker_pendreason_factory_audit.c:60-80. - Findings:
scheduler_srcslists every source; many test targets link it.test_seqjob_allocator.c,test_seqjob_rewrap_equivalence.c,test_parjob_combinatorial.cwhite-box#includethe allocator.cpluscore.c,resource.c,schedobj.c,host_iter.c. The factory audit scans a hard-coded file list. - Implications: New source goes into
scheduler_srcs, into the white-box tests'#includelists, and into the audit's file list.
File size cap
- Findings:
src/scheduler/CLAUDE.md: 800-line cap.parjob_allocator.cis 814 lines,scheduler.c2002 (pre-existing exceptions, not precedent). - Implications: The runner goes into a new file, not
scheduler.c; parjob drops below the cap.
Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|---|---|---|---|---|
| Template method via iface (chosen) | Framework runner owns the algorithm; plugin supplies plan iterator + optional accepts |
One copy of the algorithm; matches picker-iface precedent; new allocators need only a plan iterator | Plugin ABI change | Brief Approach, "Framework runner, lean" |
| Shared helpers only | Extract helpers, keep each my_alloc() |
Smallest diff | Plugins still own the loop; fails item 4 | Rejected in brief |
Runner + commit hook |
Also let plugins override result assembly | Future flexibility | Unused extension point needing its own tests | Rejected in brief |
Design Decisions
Decision: Iface lives in alloc_iter.h, forward-declared in plugin.h
- Context: Req 1.2, 1.6;
plugin.hcannot see plan-iterator types. - Alternatives Considered:
- Full struct in
plugin.h(needs a named tag onxce_alloc_plan_tand forward declarations of resreq types). - Forward typedef in
plugin.h, full struct inalloc_iter.h(picker pattern).
- Full struct in
- Selected Approach: 2.
- Rationale: No header cycle, no change to
xce_alloc_plan_t;alloc_iter.his already the allocation-contract header both plugins include. - Trade-offs: The iface is not visible from
plugin.halone; plugin authors includesched/alloc_iter.h, which they already do.
Decision: Claims buffer grows with the largest plan, not bound by maxTasks
- Context: Req 3.8, 5.3; brief item 5 (no VLA). seqjob accepts any
maxTasks(0 reaches it from direct callers), somaxTasksis not a safe bound. - Selected Approach: A heap buffer owned by the attempt, grown with
realloctoplan->nbucketsbefore trying a plan that needs more entries; freed on every exit. - Trade-offs: One extra error path (
-ENOMEMon growth, taken between plans with no claims outstanding); no stack-size risk for large task counts.
Decision: Result built into a local, published to ctx->alloc last
- Context: Req 4.4, 5.4, 5.6, bug-1882.
- Selected Approach:
sched_finalize_job_alloc_()builds thexce_job_alloc_tincluding shared records in a local and assignsctx->alloconly on success. - Rationale: Today's code sets
ctx->allocearly and relies onfree_JobAlloc_ptrNULLing it; building locally makes Req 5.6 structural. Observable result is identical.
Decision: Staged migration
- Selected Approach: (1) retype plan-iterator
init; (2) characterization tests that drive each plugin throughplugin->job_alloc_fnagainst the current code; (3) runner + iface, parjob switched; (4) seqjob switched; (5) closure (comments, audit list, item 4). Each stage leaves the suite green. - Rationale: Characterization tests written through
job_alloc_fnpass before and after the switch, giving Req 7.2's equivalence evidence without keeping the old code.
Synthesis outcomes
- Generalization: seqjob is parjob's algorithm with one plan of one one-slot bucket; the runner is written once for the general case (N plans × M buckets), and seqjob needs no special path.
- Build vs adopt: internal refactor; nothing external applies. The claim/rollback/snapshot helpers are adopted from parjob unchanged in behaviour, only renamed and moved.
- Simplification: no
commithook;acceptsis optional (NULL for seqjob); helper functions staystaticin the runner file (tests reach them by white-box#include, the project's existing convention) instead of widening the scheduler header.
Decisions added in design review (2026-09-29)
- Snapshot ownership handoff:
sched_try_plan_()owns claim snapshots until it returns 1, thensched_finalize_job_alloc_()owns and frees all of them; the runner never frees one. Prevents a double free between the finalize failure path and the runner's cleanup. - Thread cancellation:
plan_nextandsched_host_iter_next()are cancellation points; no scheduler code usespthread_cleanup_push(). Cancel happens only at shutdown, so a mid-attempt cancel keeps abandoning attempt-scoped heap as today (the claims buffer joins that set). No cleanup handlers added. - Per-bucket reset side effects: seqjob now runs the prepare fan-out on hosts the stale pre-filter used to skip, so the limit plugin's lazy host-limit instantiation (
limit.cmatch_job_host_limit_:obj->hlimits,instantiate_limit,worker->resvals) can happen earlier. Decision-neutral; extra cost per short host per pending seqjob attempt, bounded by the unsatisfiable cache. Recorded in brief item 1. - Characterization tests reuse the existing
plugin_prepare_allocation()stub convention (test_seqjob_allocator.c:84); limit fill stays covered bytest_sim_alloc_limit_fanoutthrough the realplugin.c. seqjob-EINVALfixtures come after the switch (today's code dereferences NULL). - Retype scope includes
test_alloc_plan_iter.c's stub iterator.
Risks & Mitigations
- Unintended behaviour change in seqjob beyond the listed ones — characterization tests written before the switch, run unchanged after; sim parity tests.
- White-box tests fail to link after the move — the runner file is added to each white-box test's
#includelist in the same task that deletes the plugin code. - bug-1892 (positional uncommitted read in
unsatisfy_) makes some COMPOUND outcomes wrong today — out of scope; COMPOUND characterization tests avoid asserting outcomes that depend on it. parjob_allocator.candseqjob_allocator.ccomments referencemy_alloc()extensively; stale comments mislead — comment cleanup is its own closure task.
References
.kiro/specs/scheduler-allocator-runner/brief.md— decisions 1–6..kiro/specs/scheduler-allocation/— plan/host iterator contracts (upstream)..wolf/bugs/bug-1892.md,.wolf/bugs/bug-1893.md.