myxce/.kiro/specs/resreq-maxtasks-wiring/research.md
Rongsong Shen 799a71ecb1
feat(resreq-maxtasks-wiring): add task-count derivation helper
Adds apply_job_task_count() to overwrite a standalone primary resreq's
minTasks/maxTasks with a job's real requested task count, no-op for
COMPOUND/ALTER nested primaries, with a defensive min>max clamp.
Closes the gap that lets scheduler-allocation's multi-host allocator
ever be reached (bug-1770). Wiring at live ingest call sites follows
in subsequent tasks.

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

115 lines
24 KiB
Markdown

# Research & Design Decisions
## Summary
- **Feature**: `resreq-maxtasks-wiring`
- **Discovery Scope**: Extension (existing system) — light discovery
- **Key Findings**:
- `parse_resreq_primary()`/`parse_resreq()` take no job-context parameter at all, and 6 existing test call sites invoke them directly with the bare production signature — an "add a parameter" fix would break every one of them.
- There are 5 real (non-test) call sites, not 3: fresh ingest (`core_build_job()`, `make_sched_job()`), snapshot restore (`snapshot_load.c`), and two previously-uncounted job-update handlers (`sched_update_existing_job()`, `core_events.c`'s `JOB_UPDATE` handler) that also re-parse a job's resreq.
- A single job's resreq parse tree can contain multiple primaries when `COMPOUND`/`ALTER` syntax is used; only the job's own top-level standalone primary (`tag == XCE_RESREQ_PRIMARY`) should receive the job's task count — nested primaries must stay untouched to satisfy Requirement 3.3's non-regression guarantee.
- `minTasks`/`maxTasks` are not independently persisted in the WAL/snapshot wire format — they are always re-derived at parse time from `job_submit_data_t.mintasks/maxtasks`, which is already correctly round-tripped. Fixing the derivation fixes both fresh-ingest and snapshot-restored jobs with no new wire schema.
- `xcecd_virtual`'s multi-host relay code (`job_start_remote_task()`, `create_mpi_cmdline()`, `handle_runtasks()`) is completely unguarded by `VIRTUAL_CLUSTER` — it is byte-identical to the real worker's code, and multiple named virtual-worker instances can already run concurrently. No new worker-side test infrastructure is needed.
- Today, the only way to produce a real `nhosts > 1` dispatch end-to-end is `xce_test`'s `jobrun` debug bypass, which hand-constructs the allocation and skips the scheduler's own decision entirely. Once this spec's fix lands, a plain `jobsub -n <count>` against a real scheduler with multiple real workers should produce a genuine multi-host split on its own — proving that is the actual point of this spec's E2E test, not exercising the pre-existing worker-side code (which was never broken).
- Of the 5 parser call sites, only 2 are reachable in production today: `core_build_job()` and the snapshot-restore path. `make_sched_job()` (and its own caller chain, `sched_update_jobs()`/`sched_update()`) is dead code — confirmed by exhaustive grep and by pre-existing comments already in the codebase, and matching the identical, already-fixed sibling case bug-1740 established for `sched_apply_job_rusage()`. `sched_update_existing_job()` is dead for the same reason. `core_events.c`'s `JOB_UPDATE` handler is live but functionally inert for scheduling (bug-1773) and, separately, never receives a genuinely-changed resreq today since no live feature mutates a job's resreq post-submission.
- "Snapshot restore" (`snapshot_load.c`) is live only through the `--sim FILE` offline simulation/replay driver (`sim.c`'s `sim_locate_and_load_snapshot()`), not through any live-cluster crash-recovery or post-failover path. A live scheduler restart rejoins via `on_scheduler_join()` → the same `core_build_job()` path fresh ingest uses, never touching `snapshot_load.c`. This means the HA soak provides zero coverage of that call site; a dedicated comparative test (Requirement 5.6) closes the gap. bug-1772's own original phrasing ("post-restart or post-failover restore") was corrected to reflect this.
- Separately, "write-ahead-log restore" (Requirement 1.3's original wording) turned out not to be a third code path at all: a controller-restart's WAL-replayed job state reaches the scheduler via `on_scheduler_join()` → the identical `core_build_job()` path fresh ingest uses (confirmed by tracing `job.c`'s `restore_job()`/`restore_job_array()` → `sched.c`'s `on_scheduler_join()` → `xcesched_push_jobs()` → `handler.c`'s `sched_on_jobs()` → the same driver-translated ingest). Requirement 1.3 was reworded to refer only to the simulation/replay driver's snapshot loading, since that's the only genuinely distinct second ingest path that exists.
## Research Log
### `parse_resreq_primary()` call graph and fix-shape constraints
- **Context**: Requirements demand the job's real task count reach `xce_primary_resreq_t.minTasks/maxTasks`; needed the exact current code shape to pick a fix mechanism.
- **Sources Consulted**: Direct reads of `src/lib/resreq.c`, `src/include/resreq.h`, `src/scheduler/src/core_events.c`, `src/scheduler/src/schedobj.c`, `src/scheduler/src/snapshot_load.c`, and every test file calling either function.
- **Findings**:
- `parse_resreq_primary(xce_primary_resreq_t*, char*, var, char**)` and `parse_resreq(xce_resreq_t*, char*, var, char**)` (`resreq.c:391-392`, `:448`) take no job object.
- 5 real call sites: `core_build_job()` (`core_events.c:247`), `make_sched_job()` (`schedobj.c:182`), snapshot restore (`snapshot_load.c:438`), `sched_update_existing_job()` (`schedobj.c:638`), and the `JOB_UPDATE` handler (`core_events.c:1221`).
- At every ingest call site, the job's real `mintasks`/`maxtasks` is available in scope at or immediately after the parse call, either via `j->submit->mintasks/maxtasks` (or `wj->submit->`) or, at the two update sites, via the already-resolved `job->mintasks/maxtasks` (the update handlers never refresh task count from `updated->submit`, so re-applying the job's existing value is the conservative, scope-preserving choice).
- 6 test call sites (`test_resreq.c` x3, `test_core_events.c:1520`, `test_scheduler_cache_dispatch_compound.c:312/315`, `test_snapshot_load_allocator_regression.c:206`) call the bare 4-arg signature directly with no job context — ruling out any signature change.
- `xce_primary_resreq_t` (`resreq.h:238-252`) has no field or comment documenting the relationship between its own `minTasks/maxTasks` and the job-level pair — this duplication (one job, potentially several primaries in a `COMPOUND`/`ALTER` tree, each historically hardcoded to 1/1) is confirmed by `scheduler.c:192-196`'s comment that `parse_resreq_primary()` runs "uniformly for EVERY primary in a resreq's parse tree... not only top-level ones."
- **Implications**: The fix must be an overwrite performed by each caller immediately after the parse call returns (not a parser-signature change), gated on the resreq's top-level tag being `XCE_RESREQ_PRIMARY` so `COMPOUND`/`ALTER` nested primaries are left exactly as today.
### Test infrastructure for a real multi-host execution proof
- **Context**: Requirement 4/5 need an automated test proving a real multi-host job launches and runs, but the controller/worker multi-host code has zero existing automated coverage.
- **Sources Consulted**: `.claude/skills/xce-test-ha/run-ha-soak.sh`, `.claude/skills/xce-deploy-ha/SKILL.md`, `.claude/skills/xce-test-resources/`, `src/worker/meson.build`, `src/worker/src/controller_connector.c`, `src/controller/testsuite/clean_failover_preservation.sh`, `src/controller/testsuite/test.c`'s `cmd_jobrun`.
- **Findings**:
- `xcecd_virtual` is the same `xcecd_srcs` as the real worker, built only with `-DVIRTUAL_CLUSTER`; that macro gates identity/signal/timing shortcuts, never the multi-host relay code path. Multiple named virtual-worker instances can run concurrently (unlike real `xcecd`, which is single-instance per host).
- `run-ha-soak.sh`'s `submit_worker_job()`/`verify_worker_rejoin_resync()` already proves the idiom for "submit real job via real `jobsub`, poll for real `RUN` then `DONE` against a real worker" — single-host only, but directly reusable as a pattern.
- `xce_test`'s REPL (including `jobrun`) is already driven non-interactively via piped stdin in three existing scripts — no new automation mechanism needed.
- No existing automated (meson/Unity) test spawns real, separate controller+worker processes; the one script that spawns real controller processes (`clean_failover_preservation.sh`) never starts a worker and isn't wired into meson.
- **Implications**: Build a new, small standalone script (same shape as `run-resource-verify.sh`/`clean_failover_preservation.sh`, single controller node — no HA needed for this proof), deploying two named `xcecd_virtual` instances and driving a real `jobsub`. Critically, drive it via plain `jobsub -n <count>`, not `jobrun`'s manual-allocation bypass — proving the *scheduler itself* decides to split across hosts is the actual deliverable of this spec; the worker-side launch code is pre-existing and unmodified.
### Persistence field-completeness (per `persistence.md`'s review requirement)
- **Context**: `xce_primary_resreq_t.minTasks/maxTasks` transitions from a compile-time constant to a real, job-derived value — steering requires an explicit per-field save/restore audit for any such change.
- **Findings**: `minTasks`/`maxTasks` are never independently serialized to the WAL or snapshot wire format. `snapshot_wire.c` persists `job_submit_data_t.mintasks/maxtasks` (the job-level source of truth) directly; `snapshot_load.c` restores it and then calls the same `parse_resreq()` chain fresh, which is exactly the derivation this spec fixes.
- **Implications**: No new wire field, no new snapshot schema, no round-trip gap introduced. Fixing the shared derivation function's callers fixes fresh-ingest and snapshot-restored jobs identically and simultaneously — this satisfies the persistence steering's field-completeness bar by construction, not by omission.
## Design Decisions
### Decision: Overwrite-after-parse via a shared helper, not a parser-signature change
- **Context**: `xce_primary_resreq_t.minTasks/maxTasks` must reflect the job's real task count.
- **Alternatives Considered**:
1. Add job-context parameters to `parse_resreq_primary()`/`parse_resreq()` — rejected: breaks 6 existing test call sites that call the bare signature directly, and doesn't cleanly express "only the top-level standalone primary, never nested ones."
2. Duplicate an inline overwrite at each of the 5 call sites — rejected: repeats the `COMPOUND`/`ALTER` tag-check five times (violates Simplification), harder to keep consistent.
3. **Selected**: a single new helper, `apply_job_task_count()`. Originally scoped to all 5 real call sites; a later design review found 2 of the 5 (the job-update handlers) don't affect dispatch at all (`job->schedobj` is never rebuilt on update — see the dedicated Decision below and bug-1773), and a follow-up review found a 3rd (`make_sched_job()`) is dead code entirely (unreachable via `sched_update()`, which has zero live callers — see the dedicated Decision below and bug-1774's correction), narrowing this to the 2 live ingest call sites whose result actually reaches dispatch: `core_build_job()` and the snapshot-restore path.
- **Rationale**: Matches the codebase's own existing idiom (an un-abstracted field-copy from `submit->` onto the target struct right after it's built, already done 5 times for `job->mintasks/maxtasks` itself) while centralizing the one piece of real logic (the tag-gate and the defensive clamp) in one testable place.
- **Trade-offs**: Five call sites still each need one new line invoking the helper — irreducible given the parser's job-context-free signature, but each line is trivial and independently reviewable.
- **Follow-up**: `test_parjob_e2e_multislot.c` (scheduler-allocation feature)'s `PATCH_MAXTASKS_FOR_BUG_1770` workaround becomes obsolete once this fix lands and should be removed in favor of driving that test through real ingest — closing the loop in the same test that originally characterized the bug.
### Decision: E2E proof drives real `jobsub`, not the `jobrun` debug bypass
- **Context**: Need an automated test proving real multi-host execution; `jobrun` already provides a way to force a multi-host dispatch today.
- **Alternatives Considered**:
1. Use `jobrun`'s manual `-m host:ntasks` allocation construction — proves the controller/worker wire path but bypasses the scheduler's own decision, which is not what this spec changes.
2. **Selected**: plain `jobsub -n <mintasks,maxtasks>` against two real workers sized so neither alone can satisfy the request.
- **Rationale**: This spec's entire value is that the scheduler *itself* now correctly decides to span multiple hosts. A test that hand-constructs the allocation would pass identically whether or not this spec's fix exists, proving nothing about it.
- **Trade-offs**: None material — `jobsub`'s real dispatch path already includes everything `jobrun` would need to prove, once the fix lands.
### Decision: Scheduler-side min/max clamp is defensive-only, not a new rejection path
- **Context**: Requirement 1.4 requires the Scheduler not let an invalid task-count pair reach allocator dispatch; upstream (apiserver) already guarantees `mintasks <= maxtasks` twice before any job reaches the C layer.
- **Selected Approach**: `apply_job_task_count()` clamps `maxtasks` up to `mintasks` if violated (mirroring `convert.go`'s exact clamp direction), rather than rejecting the job.
- **Rationale**: Matches this project's "graceful degradation over complete failure" principle and does not introduce a new failure mode for a codepath that should be unreachable today; still gives Requirement 1.4 a real, testable invariant rather than one vacuously true by construction.
### Decision: Job-update call sites are out of implementation scope
- **Context**: An independent design review found that `job->schedobj->u.primary->resreq` — the object the scheduler actually dispatches on — is set once at ingest and never rebuilt by either job-update handler, which only replace `job->resreq` itself.
- **Alternatives Considered**:
1. Apply the helper at both update call sites anyway, for `job->resreq`'s own internal consistency — rejected: it would be dead code with zero effect on dispatch, and could mislead a future reader into thinking it matters.
2. Fix the underlying gap (rebuild `job->schedobj` on update) as part of this spec — rejected: this is a substantially larger, unrelated fix (an update to ANY field, not just task count, never takes effect for scheduling today) that belongs in its own spec.
3. **Selected**: drop the two update-site changes entirely; reword Requirement 1.4 to describe the guarantee that already holds structurally; add a regression test proving it; log the underlying gap separately (bug-1773).
- **Rationale**: Matches the Simplification design principle — no production code should exist that has no observable effect. The regression test still gives Requirement 1.4 real, ongoing coverage without shipping dead code.
- **Follow-up**: bug-1773 (job-update never taking effect for scheduling, any field) is a real, independently valuable fix for a future spec.
### Decision: `make_sched_job()` is dead code — dropped from implementation scope entirely, not merely deferred
- **Context**: An independent review initially found `make_primary_schedobj()` (backing `make_sched_job()`) never computes `resreq_fp`, identical to bug-1772's snapshot-restore gap, and logged it as bug-1774 with Requirement 5.4 scoped to exclude that path. A SECOND, follow-up independent review then established that `make_sched_job()`'s entire call chain is unreachable in production: its only caller, `sched_update_jobs()`, is only called from inside `sched_update()`'s switch, and `sched_update()` (`scheduler.c`) has zero live callers anywhere in this codebase — confirmed by exhaustive grep (only the declaration, definition, and doc-comments describing it as dead/superseded exist) and corroborated by the identical, already-fixed sibling case in bug-1740 (`sched_apply_job_rusage()`, reachable only via the same dead path, fixed by moving the logic to the live `core_events.c` path instead).
- **Alternatives Considered**:
1. Fix `make_sched_job()`'s `resreq_fp` gap now, since this spec already touches this exact class of code — rejected even before the dead-code finding, to stay consistent with the user's own prior scoping decision for bug-1772's sibling instance; rejected even more strongly once dead-code status was confirmed, since fixing unreachable code has zero production value.
2. Wire `apply_job_task_count()` into `make_sched_job()` anyway, for code-path completeness — rejected: it would be dead code applying a fix to dead code, the same "no production code with no observable effect" concern that excluded the job-update call sites.
3. **Selected**: drop `make_sched_job()`/`schedobj.c` from this spec's implementation scope entirely (not merely exclude it from one test's guarantee); correct bug-1774's original "confirmed live" claim (retracted, tagged dead-code/severity-low); move Requirement 5.4's actual exception to bug-1772 (the snapshot-restore gap, which IS live) instead.
- **Rationale**: Matches the Simplification design principle even more directly than the job-update decision above — there is no reason to wire, test, or even discuss a fix for code with zero live callers, beyond noting its existence for whoever eventually revives or removes it.
- **Follow-up**: bug-1774 remains open as a low-priority, dead-code-only finding; bug-1772 (now Requirement 5.4's real exception) should ideally be picked up alongside bug-1767's broader recommended remediation (share one real schedobj-construction implementation across the live call sites, rather than maintaining independent, drift-prone copies — including the dead one, if it's ever revived).
### Decision: Requirement 1.3 reworded, new Requirement 5.6 added, to accurately scope "snapshot restore"
- **Context**: A third independent review, applying the same liveness rigor now applied to `make_sched_job()`, found `snapshot_load.c`'s restore path is live only via the `--sim` offline simulation/replay driver — not live-cluster crash recovery or post-failover restart, which instead rejoins through the identical `core_build_job()` path fresh ingest uses. This meant Requirement 5.2's HA soak was implicitly (and wrongly) credited with covering this call site, and Requirement 1.3's "write-ahead-log restore" phrasing implied a third code path that doesn't actually exist.
- **Alternatives Considered**:
1. Leave the wording as-is, since the implementation plan (wire `apply_job_task_count()` into `snapshot_load.c`) doesn't change — rejected: a reader would reasonably conclude the HA soak validates this path, which it does not, understating an actual test-coverage gap.
2. **Selected**: reword Requirement 1.3 to name the simulation/replay driver specifically (dropping "write-ahead-log restore," which turned out to describe the same live path as fresh ingest, not a distinct one); add Requirement 5.6 requiring a direct comparative proof (snapshot-loaded task count equals what fresh ingest would derive for the same input); clarify Requirement 5.2's AC text that the HA soak doesn't exercise this path; correct bug-1772's own trigger description in the buglog.
- **Rationale**: The fix and its value (correct task count for `--sim`/tuning fidelity, per this project's driver-equivalence invariant) are unchanged — only the test-coverage claim needed correcting. An honest AC set with a real, explicit line of coverage is better than an implicit, incorrect one.
- **Follow-up**: none — `test_maxtasks_wiring_ingest.c`'s existing white-box test for the snapshot-restore call site already provides a natural home for the new comparative assertion; no new test file or infrastructure is needed.
### Decision: white-box MPI-launch test targets `job_start_remote_task()`'s gate, not `create_mpi_cmdline()` alone
- **Context**: A fourth independent review found the originally-planned test target, `create_mpi_cmdline()`, is a pure command-string builder one layer below the actual decision point. `job_start_remote_task()` (`controller_connector.c`, the real entry point `handle_startjob()` calls for `nhosts > 1`) has its own independent launch-type gate deciding whether to defer to MPI's own remote-launch mechanism or fan out via XCE's per-host dispatch — the actual behavior Requirement 4.4 depends on, with zero existing test coverage of any kind.
- **Selected Approach**: retarget `test_worker_launch_type_dispatch.c` primarily at `job_start_remote_task()`'s gate (correct no-op for MPI launch types, correct fan-out for the default type), secondarily at `job_start_local_task()`'s branch into `create_mpi_cmdline()`.
- **Rationale**: A regression in `job_start_remote_task()`'s gate would cause a real multi-host MPI job to be double-launched or wrongly launched — testing only the command-string builder one layer down would not catch that.
- **Follow-up**: none — same file, same white-box `#include` technique already precedented by `test_controller_connector.c`.
## Risks & Mitigations
- Note (non-blocking, documentation-only): `apply_job_task_count()`'s clamp never separately validates `mintasks == 0`. Confirmed unreachable from either real live call site (`convert.go`'s and `xce_test`'s `jobsub`'s own independent `mintasks == 0 -> 1` clamps both predate this feature), and even a hand-crafted `--sim` snapshot with a genuinely-zero `mintasks`/`maxTasks` degrades gracefully via `scheduler-allocation`'s own pre-existing `maxTasks <= 1` bail in `parjob_allocator.c` (a job pends as unsatisfiable, no crash). Not a defect; worth a one-line mention in Error Handling for a future reader's benefit, not a required fix.
- ~~Risk: a sixth, undiscovered call site to `parse_resreq()`/`parse_resreq_primary()` exists that this design missed~~ — RESOLVED: independently re-verified twice by separate design-review passes (exhaustive grep each time), no additional caller found beyond the original 5. No longer an open risk, though the implementation task should still re-grep once more immediately before wiring as cheap insurance.
- ~~Risk: driver/simulation divergence in `core_events.c`~~ — RESOLVED during design review: independently confirmed `driver.c` and `sim_loop.c` both reach `core_build_job()` through the same shared `xce_core_push_events()`, no parallel sim-specific job constructor exists. No longer an open risk.
- Risk (discovered during design review, now mitigated): the job-update call sites don't affect scheduling at all, because `job->schedobj` is never rebuilt on update — logged as bug-1773, removed from this spec's implementation scope, Requirement 1.4 reworded and given a structural regression test instead.
- Risk (discovered during design review, now mitigated): initially thought `make_sched_job()`'s ingest path had the same `resreq_fp` gap as bug-1772 (logged as bug-1774) and needed excluding from Requirement 5.4's test — a follow-up review found `make_sched_job()` is dead code entirely (unreachable in production), so it's dropped from implementation scope outright; Requirement 5.4's real exception is bug-1772 (snapshot-restore), corrected accordingly.
- Risk: the new E2E script's two-virtual-worker deployment introduces soak-infrastructure flakiness (per this project's documented zombie-process/port-conflict soak gotchas) — Mitigation: follow the same pre-flight process/port check convention already established for `xce-test-ha`.
- Risk: fixing task-count wiring exposes bug-1768's compound-nested resource-checking gap or bug-1771's fcfs NULL-queue crash under new, previously-unreachable conditions — Mitigation: both remain explicitly out of this spec's boundary; the new tests target standalone primaries and array jobs only, matching Requirement 3.3's scope.
## References
- `.wolf/buglog.json` bug-1770, bug-1768, bug-1769, bug-1771, bug-1772, bug-1773, bug-1774 — prior findings this spec builds on or explicitly defers. bug-1773 and bug-1774 were discovered during this spec's own design review; bug-1772 and bug-1774 both received a trigger/liveness correction during the same review (bug-1774's original "confirmed live" claim retracted; bug-1772's "post-restart or post-failover" phrasing corrected to "the `--sim` simulation/replay driver").
- Minor, non-blocking cleanup noted but out of this spec's file boundary: `core.c:354` has a stale comment referring to `make_sched_job()` as "still-live" — harmless (prose, not code) but inaccurate after this review's dead-code finding; worth fixing whenever that file is next touched.
- `.kiro/specs/scheduler-allocation/` — the dependency spec whose allocator logic this fix makes reachable.
- `.kiro/steering/persistence.md` — "Snapshot Save/Load Field-Completeness" section, cross-checked above.