myxce/.kiro/specs/scheduler-allocator-runner/research.md
Rongsong Shen 31ad79d325
feat(scheduler-allocator-runner): declare the allocator plugin contract (task 1.2)
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>
2026-09-30 07:49:25 +00:00

109 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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's `maxTasks <= 1` guard and NULL-input guard, parjob's per-bucket `alloc_req.host` reset, per-candidate `nslots/nhosts` assignment, and claim bookkeeping (parjob snapshots each claim's `host_req`; seqjob keeps a single `w` and reads the live `alloc_req.host` at commit). Everything else is duplicated text.
- `xce_alloc_plan_t` is an anonymous typedef declared in `alloc_iter.h`, which itself includes `scheduler.h` → `plugin.h`, so the full allocator iface cannot live in `plugin.h` without either a header cycle or a tag change. The picker iface already solves the same problem with a forward typedef in `plugin.h`.
- Three white-box tests call the static `my_alloc()` / `parjob_try_plan_()` / `parjob_rollback_claims_()` directly and stub `sched_make_allocs()` (bug-1882 OOM injection); none asserts pend-reason content. `test_parjob_combinatorial.c` passes `NULL` and a poisoned `xce_scheduler_t*` as the plan iterator's third argument.
## 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_RESOURCE` fill → `xvec_idval(uncomit, &shared, XVEC_ADD_FLOAT)`. The fill loop is bounded by `alloc_req.shared.count` over `ctx->unmeet` (bug-1893).
- Plan-init failure returns 0 in both (no `fini`).
- parjob: `claimed[primary->maxTasks]` VLA; per bucket: reset `alloc_req.host`, host iter, skip claimed, set `nslots = bucket_slots[b]`, `nhosts = 1`, `sched_try_alloc_on_host_() > 0` → snapshot `alloc_req.host` (OOM → uncommit candidate with live request + rollback earlier claims → return rc).
- Commit tail: parjob `calloc` of `hallocs`, per claim `sched_make_allocs(sched, "", nres, idxs, vals, NULL)` + `make_host_alloc`; failure rolls back claims `[i, n)` and frees `hallocs`; then `make_job_alloc(0, n, hallocs)`; then the shared-record block. seqjob uses `make_host_allocs(1, halloc)`, which produces the same one-element array.
- Both set `ctx->alloc` before building the shared records and `free_JobAlloc_ptr(&ctx->alloc)` (which NULLs it) on failure.
- **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:682` job-level pass, `:699` per-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()` with `ctx->worker` set clears and refills `alloc_req.host` per candidate (`plugin.c:639`), so the authoritative check always uses a fresh request. `sched_host_iter_next()` pre-filters against whatever `alloc_req.host` holds before that refill and skips failures silently. `add_job_preason(..., host, res)` writes `res_based_reasons`, never `main_reason` (`lib/preason.c:147`); the unsatisfiable cache reads `main_reason` only (`scheduler.c:271`); sim parity compares `main_reason` only (`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.h` includes only `stdint.h`, `stdlib.h`, `decision.h`, `resreq.h` and `sched/types.h` (corrected 2026-09-30 during task 1.2: discovery misread a truncated listing as "no includes"); `core.h` and `scheduler.h` include it. `alloc_iter.h` includes `scheduler.h`. `picker_iface` is a forward-declared `xce_job_picker_iface_t*` pointing at static storage that the registry never frees (`plugin.c:111,149`). Plugin templates use designated initialisers and are `memcpy`'d at registration.
- **Implications**: Forward typedef in `plugin.h`; full struct and runner prototype in `alloc_iter.h`; iface storage `static const` in 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()` against `sim->core` and sets `plugin->sched` to a stand-in wrapper used only by `sched_make_allocs()`. So `plugin->sched` is 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_srcs` lists every source; many test targets link it. `test_seqjob_allocator.c`, `test_seqjob_rewrap_equivalence.c`, `test_parjob_combinatorial.c` white-box `#include` the allocator `.c` plus `core.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' `#include` lists, and into the audit's file list.
### File size cap
- **Findings**: `src/scheduler/CLAUDE.md`: 800-line cap. `parjob_allocator.c` is 814 lines, `scheduler.c` 2002 (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.h` cannot see plan-iterator types.
- **Alternatives Considered**:
1. Full struct in `plugin.h` (needs a named tag on `xce_alloc_plan_t` and forward declarations of resreq types).
2. Forward typedef in `plugin.h`, full struct in `alloc_iter.h` (picker pattern).
- **Selected Approach**: 2.
- **Rationale**: No header cycle, no change to `xce_alloc_plan_t`; `alloc_iter.h` is already the allocation-contract header both plugins include.
- **Trade-offs**: The iface is not visible from `plugin.h` alone; plugin authors include `sched/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), so `maxTasks` is not a safe bound.
- **Selected Approach**: A heap buffer owned by the attempt, grown with `realloc` to `plan->nbuckets` before trying a plan that needs more entries; freed on every exit.
- **Trade-offs**: One extra error path (`-ENOMEM` on 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 the `xce_job_alloc_t` including shared records in a local and assigns `ctx->alloc` only on success.
- **Rationale**: Today's code sets `ctx->alloc` early and relies on `free_JobAlloc_ptr` NULLing 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 through `plugin->job_alloc_fn` against 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_fn` pass 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 `commit` hook; `accepts` is optional (NULL for seqjob); helper functions stay `static` in 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, then `sched_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_next` and `sched_host_iter_next()` are cancellation points; no scheduler code uses `pthread_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.c` `match_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 by `test_sim_alloc_limit_fanout` through the real `plugin.c`. seqjob `-EINVAL` fixtures 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 `#include` list 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.c` and `seqjob_allocator.c` comments reference `my_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`.