Found during task 1.1 implementation: the Data Structure Changes Summary declared next() returning xce_worker_t* (the wire/config type), but the Composition Algorithm pseudocode in the same document already used xce_sched_worker_t* (the scheduler-internal type with resvals/resInUse/host_eval_ctx) for the identical call -- these are genuinely different types in this codebase, confirmed via make_sched_worker()'s xce_worker_t* -> xce_sched_worker_t* conversion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XYdtrEWWZfKRRGED7raEYg
42 KiB
Design Document
Overview
Purpose: This feature turns the scheduler's allocation layer from a partially-stubbed, one-shot mechanism into a real, retry-capable, combinatorial allocator, and adds a session-scoped cache so batches of identically-unsatisfiable jobs don't each re-pay full allocation cost.
Users: Cluster operators submitting parallel (maxTasks > 1) jobs, who today get silent, permanent allocation failure regardless of actual cluster capacity; and scheduler maintainers who today cannot add a new allocation strategy without reimplementing host collection/filtering from scratch.
Impact: parjob_allocator.c's my_alloc() goes from an unconditional return 0 stub to a real combinatorial allocator. seqjob_allocator.c's my_alloc() is rewrapped onto the same iterator interfaces as a trivial case, with no change to its externally observable host-selection behavior. scheduler.c gains a session-scoped unsatisfiable-resreq cache wrapping the existing PRIMARY-resreq allocator dispatch.
Goals
parjob_allocatorallocates real multi-host, multi-slot jobs via lazy combinatorial plan generation (Requirement 4).- A reusable lazy plan-iterator (Requirement 2) and host-iterator (Requirement 3) interface, shared by every current and future allocator plugin.
- A session-scoped unsatisfiable-resreq fingerprint cache (Requirement 7) that skips full allocation for a job whose resreq this session already proved unsatisfiable.
seqjob_allocator's externally observable behavior is provably unchanged after rewrap (Requirement 9.1).
Non-Goals
- Pluggable job picker,
builtin_fcfs, per-queue picker override — scheduler-core (already implementation-complete). - Preemption, victim selection,
XCE_ALLOC_PREEMPT's actual resource-borrowing behavior — scheduler-preemption (this spec only declares thexce_alloc_mode_textension point it needs). - Core/driver separation — scheduler-core (already implementation-complete).
span/same/affinity-aware plan generation, topology/GPU-aware allocation, resreq template registry, select-expression changes — deferred; the iterator interfaces are shaped to accept these later without a signature change.- Controller-side commit-barrier wiring (
ha-consistency-invariants) — this spec produces allocation decisions the barrier will eventually consume, does not wire it. - Fixing
sched_make_compound_allocation()'s pre-existing missing-rollback-on-partial-failure defect (bug-1766) or the recurring schedobj-reconstruction-duplication pattern insnapshot_load.c(bug-1767) — both discovered during this spec's own review, both out of boundary, both logged for independent follow-up.
Boundary Commitments
This Spec Owns
- The
xce_alloc_plan_iter_tinterface and every concrete plan-iterator implementation (parjob_allocator's combinatorial one,seqjob_allocator's trivial one). - The
xce_host_iter_tinterface and its one shared implementation, reused by every allocator. parjob_allocator.c'smy_alloc()/parjob_prepare_allocation()bodies — real implementations, replacing the stubs.seqjob_allocator.c'smy_alloc()/seqjob_prepare_allocation()bodies — rewrapped onto the shared iterator pattern, behavior-preserving.- The session-scoped unsatisfiable-resreq cache:
xce_resreq_fingerprint_t,xce_cache_key_t,cache_key_ops(kv_key_ops_t), and thexce_scheduler_t.unsatisfiable_cachefield's full lifecycle (populate, lookup, clear). - The
xce_alloc_mode_tenum andxce_sched_ctx_t.alloc_modefield — declared here as an extension point; onlyXCE_ALLOC_NORMALbehavior is implemented. rusage_hashonxce_primary_resreq_tandresreq_fponxce_primary_schedobj_t, and every construction site that must populate them.
Out of Boundary
COMPOUND/ALTERresreq dispatch (sched_make_compound_allocation,sched_make_alter_allocation) — calls into this spec's per-primary allocator, otherwise untouched (Requirement 9.2).- The existing select-expression evaluator, shared-resource tracking (
sharedInst/sharedInUse), and plugin framework registration mechanics — consumed, not modified. - Preemption's actual victim-selection logic and the resource-borrowing semantics
XCE_ALLOC_PREEMPTwill eventually trigger — scheduler-preemption's job. sched_make_compound_allocation()'s missing partial-failure rollback (bug-1766) and thesnapshot_load.cschedobj-reconstruction duplication pattern (bug-1767) — both pre-existing, both logged, neither fixed here.resreq_fppopulation on the snapshot-restore path (snapshot_load.c) — confirmed cosmetic (affects only offline simulation sessions,sim.c, neverdriver.c); not a requirement of this spec (seerequirements.mdRequirement 7's informational note).
Allowed Dependencies
- scheduler-core (implementation-complete) — core event interface pushing worker/resource state; this feature's iterators consume it but do not depend on the driver/I/O layer.
- scheduler-dispatch-commit (implementation-complete, bug-1624 fixed) —
resInUse/sharedInUsetotals this feature'stry_alloc_on_host()reads must already be correct; this was the spec's own sequencing blocker, now resolved. - Existing
plugin.h/plugin.cplugin framework,resreq.h's select-expression evaluator (select_eval_ctx_t),xvec.c(xvec_op(..., XVEC_SUB_FLOAT)for rollback),kv.c/kv.h(kv_key_ops_t,kvtab_type,key_find/key_add) — all consumed as-is, no changes to their own contracts. xce_job_alloc_t/make_job_alloc()/xce_host_alloc_t(xce/types.h,xcealloc.c) — already multi-host-capable; this feature is the first caller to exercisenhosts > 1.
Revalidation Triggers
- Any signature change to
xce_alloc_plan_iter_t,xce_host_iter_t, orxce_alloc_plan_t(bucket_slots[]shape) — every allocator plugin consuming them must be re-checked. - Any change to
xce_sched_ctx_t'snslots/nhosts/alloc_modefields — scheduler-preemption depends onalloc_modeexisting exactly as declared here. - Any change to
xce_cache_key_t's field set orplugin_prepare_allocation()'s fan-out semantics. scheduler-preemptionreaching implementation — its victim-selection flow wraps this spec'sallocate_primary()-shaped logic and consumesxce_alloc_mode_t; a mismatch here blocks that spec.
Architecture
Existing Architecture Analysis
sched_make_primary_alloc_()(scheduler.c:167-177) is the existing choke point everyPRIMARY-tagged job passes through: it dispatches tosched_make_seqjob_allocation()/sched_make_parjob_allocation()purely onprimary->maxTasks == 1. This dispatch itself is unchanged by this spec — only what happens inside each allocator'sjob_alloc_fnchanges.plugin_prepare_allocation()(plugin.c:591-661) unconditionally fans out to every registered plugin'sprepare_allocation_hookbefore each host-satisfaction check, resettingctx->alloc_req.host/.sharedfirst.seqjob_prepare_allocation()currently has nomaxTasksguard, so it already fires (harmlessly, since it's a no-op today) on every job regardless of allocator — this becomes load-bearing onceparjob_prepare_allocation()stops being a no-op (Requirement 4.6).xce_sched_ctx_t(sched/types.h) already carriesnhosts/nslotsscratch fields, currently hardcoded to1, 1byseqjob_prepare_allocation(); this spec is the first to vary them per allocation attempt.core_begin_session_internal()(core_session.c:152-168) already resets other shared, per-session state (core_reset_shared_resval(),core_reset_workers()) with zero explicit locking, relying onxce_core_begin_session()'s own documented ordering guarantee: all begin-session reset work completes strictly beforexce_core_signal_go()unblocks the picker/allocator threads. This spec's cache clear reuses that exact placement and safety argument rather than introducing new synchronization.xce_job_alloc_t/make_job_alloc()(xce/types.h,xcealloc.c) already generalize past one host (nhosts+xce_host_alloc_t**), but onlyseqjob_allocatorexercises them today, always withnhosts=1via the variadicmake_host_allocs(1, halloc). This spec is the first caller needing a runtime-sized host array, which the existing variadic helper cannot provide.
Project Invariant Cross-Check (per design-review-gate.md): src/scheduler/CLAUDE.md's core-isolation invariant (core.c/core_events.c/core_session.c/core_clock.c carry zero controller I/O, mechanically enforced by test_core_isolation.c) was checked against every new/modified piece of this design. All new code (xce_alloc_plan_iter_t/xce_host_iter_t implementations, the cache, both allocator plugins) is pure in-memory computation — no I/O, no blocking calls — so it does not violate the invariant regardless of which file it lands in. The one touch inside the isolated set (core_begin_session_internal()'s cache clear, core_session.c) is a plain kvtab clear, the same shape as the two existing calls already there.
Architecture Pattern & Boundary Map
graph TB
Allocator[sched_make_primary_alloc_] --> CacheCheck{Cache lookup}
CacheCheck -->|hit| Pend[Pend job immediately]
CacheCheck -->|miss| Dispatch{maxTasks}
Dispatch -->|equals 1| Seqjob[seqjob_allocator my_alloc]
Dispatch -->|greater than 1| Parjob[parjob_allocator my_alloc]
Seqjob --> PlanIterTrivial[Trivial plan iterator one bucket]
Parjob --> PlanIterCombo[Combinatorial plan iterator]
PlanIterTrivial --> HostIter[Shared host_iter]
PlanIterCombo --> HostIter
HostIter --> TryAlloc[try_alloc_on_host]
TryAlloc -->|success| Commit[Commit job_alloc]
TryAlloc -->|all plans exhausted| CacheInsert[Insert fingerprint into cache]
CacheInsert --> Pend
Architecture Integration:
- Selected pattern: two-layer lazy iterator composition (plan → host) at the
PRIMARYleaf, unchangedCOMPOUND/ALTERcomposition above it — seerequirements.md's "Composition Algorithm" for the full pseudocode (bucket-loop, host-exclusivity, rollback) this design does not restate. - Domain/feature boundaries: plan generation is allocator-owned (each
job_alloc_fnbuilds its own plan-iterator instance); host iteration is shared, reused infrastructure; the cache wraps the dispatch choke point, not either allocator individually. - Existing patterns preserved:
sched_ctx_init/sched_ctx_fini's stack-allocate-then-init-in-place convention (extended to the new iterators);plugin_prepare_allocation()'s fan-out (extended, not replaced);core_begin_session_internal()'s lock-free per-session reset pattern (extended to the cache). - New components rationale:
xce_host_iter_tis new because host collection was previously eager and allocator-specific (sched_collect_workers()+xlist_find(), inlined inseqjob_allocator.c); the cache is new because no unsatisfiable-resreq memoization existed at all. - Steering compliance: no new controller I/O (
ha-async-io.md's thread-role contract); no new persistence format, and the one persistence-adjacent question (should the cache orresreq_fpbe snapshot-captured) was resolved as "no" with a stated reason (persistence.md-style completeness review, applied inline inrequirements.md's Requirement 7 notes).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Language | C (gnu17) | All new code | No C++, no new external libraries |
| Hashing/KV | src/lib/kv.c (kvtab_type, kv_key_ops_t) |
Backs the unsatisfiable-resreq cache | Existing infra; cache_key_ops is a new, third kv_key_ops_t instance alongside int_key_ops/string_key_ops |
| Arithmetic primitives | src/lib/xvec.c (xvec_op, XVEC_SUB_FLOAT) |
Rollback's resource-debit reversal | Existing primitive, first caller to use the subtract op for this purpose |
| Concurrency | src/lib/mylock.c-style implicit ordering (no new locks) |
Cache safety | Reuses xce_core_begin_session()/xce_core_signal_go()'s existing happens-before guarantee — no new mutex introduced |
File Structure Plan
Directory Structure
src/scheduler/
├── include/sched/
│ ├── alloc_iter.h # NEW — xce_alloc_plan_t, xce_alloc_plan_iter_t,
│ │ # xce_host_iter_t interface declarations (Req 2, 3)
│ ├── alloc_cache.h # NEW — xce_cache_key_t, xce_resreq_fingerprint_t,
│ │ # cache_key_ops declaration (Req 7)
│ └── types.h # MODIFIED — xce_sched_ctx_t gains alloc_mode
│ # (Req 9.5); xce_primary_schedobj_t gains
│ # resreq_fp (Req 7)
├── src/
│ ├── host_iter.c # NEW — the one shared xce_host_iter_t
│ │ # implementation (Req 3), reused by both
│ │ # allocators
│ ├── alloc_cache.c # NEW — cache_key_hash_/cmp_/assign_/destroy_,
│ │ # cache lookup/insert/clear helpers (Req 7)
│ ├── scheduler.c # MODIFIED — sched_make_primary_alloc_() wraps
│ │ # allocator dispatch with cache lookup/insert;
│ │ # xce_ctx_uncommit_host() rollback helper (in
│ │ # resource.c, see below) called from here
│ ├── resource.c # MODIFIED — new sched_ctx_uncommit_host()
│ │ # helper wired to xvec_op(..., XVEC_SUB_FLOAT)
│ ├── core_session.c # MODIFIED — core_begin_session_internal()
│ │ # clears the cache (Req 7.2)
│ ├── core_events.c # MODIFIED — core_build_schedobj()/
│ │ # core_build_primary_schedobj() compute
│ │ # resreq_fp on ingest (Req 7.1)
│ ├── schedobj.c # MODIFIED — sched_ctx_init() gains nothing new;
│ │ # referenced for the stack-allocate-then-init
│ │ # convention new iterators follow
│ └── plugins/
│ ├── parjob_allocator.c # MODIFIED — real my_alloc() (combinatorial
│ │ # plan iterator + bucket loop over shared
│ │ # host_iter.c); parjob_prepare_allocation()
│ │ # fixed to use ctx->nslots (Req 4)
│ └── seqjob_allocator.c # MODIFIED — my_alloc() rewrapped onto the
│ # same bucket-loop pattern as a trivial
│ # 1-bucket case; seqjob_prepare_allocation()
│ # gains maxTasks==1 guard (Req 9.1, 4.6)
├── include/
│ └── scheduler.h # MODIFIED — xce_scheduler_t gains
│ # unsatisfiable_cache field (Req 7.1)
└── testsuite/
├── test_alloc_plan_iter.c # NEW (Req 2)
├── test_host_iter.c # NEW (Req 3)
├── test_alloc_cache.c # NEW (Req 7)
├── test_parjob_combinatorial.c # NEW (Req 4)
├── test_seqjob_rewrap_equivalence.c # NEW (Req 9.1)
├── test_parjob_e2e_multislot.c # NEW — integration (Req 4, brief.md scope)
└── meson.build # MODIFIED — register the 6 new executables
src/include/
└── resreq.h # MODIFIED — xce_primary_resreq_t gains
# rusage_hash (Req 7.1)
src/lib/
├── resreq.c # MODIFIED — parse_resreq_primary() computes
│ # rusage_hash (Req 7.1)
└── xcealloc.c # MODIFIED — new merge helper building a
# runtime-sized xce_host_alloc_t** array
# directly (make_host_allocs()'s variadic
# signature cannot take a runtime count),
# used by parjob's commit step (Req 4.3)
Modified Files
See inline annotations above — each modified file's change is scoped to exactly the requirement(s) noted; no file listed above is touched for any other reason.
System Flows
Combinatorial Allocation with Cache (sequence)
sequenceDiagram
participant D as sched_make_primary_alloc_
participant C as unsatisfiable_cache
participant P as parjob_allocator
participant PI as plan_iter
participant HI as host_iter
D->>C: lookup(resreq_fp)
C-->>D: miss
D->>P: my_alloc(ctx)
P->>PI: init + next (plan 1)
loop each bucket in plan
P->>HI: init fresh, next until match or exhausted
HI-->>P: worker or none
end
alt all buckets satisfied
P-->>D: success, commit
else a bucket exhausted
P->>P: rollback claimed hosts
P->>PI: next (plan 2)
Note over P,PI: repeat until a plan succeeds or plan_iter exhausted
end
P-->>D: ALLOC_FAIL (all plans exhausted)
D->>C: insert(resreq_fp)
D-->>D: pend job
Full per-bucket accumulation/rollback logic (host-exclusivity via is_claimed(), fresh host_iter per bucket, ctx->uncomitted rollback scope) is specified in requirements.md's "Composition Algorithm" — not restated here; this diagram shows only the cross-file call sequence design.md needs to fix component ownership.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 2.1–2.6 | Lazy plan iterator: interface, one-plan-per-call, ordering, exhaustion signal, cleanup, cancel-safety | xce_alloc_plan_iter_t (per-allocator impl) |
init/next/fini |
Combinatorial Allocation sequence |
| 3.1–3.6 | Lazy host iterator: interface, filtering, exhaustion, composition with plan iterator, cleanup, cancel-safety | host_iter.c (shared) |
init/next/fini |
Combinatorial Allocation sequence |
| 4.1–4.7 | Parjob combinatorial allocation: plan generation, per-bucket distinct-host accumulation, commit/merge, stub-return preserved on exhaustion, no unconditional stub, ctx->nslots resource-want plumbing, shared-resource check unchanged |
parjob_allocator, alloc_cache.c (fingerprint only, not the check itself) |
job_alloc_fn, prepare_allocation_hook |
Combinatorial Allocation sequence |
| 7.1–7.5 | Unsatisfiable-resreq cache: key/fingerprint shape and O(1) lookup, begin-session clear placement, populate-on-exhaustion, lookup-before-allocation, pend-reason consistency | alloc_cache.c, core_session.c, core_events.c |
cache_key_ops (kv_key_ops_t), key_find/key_add |
Combinatorial Allocation sequence |
| 9.1–9.5 | No regression: seqjob behavior unchanged, COMPOUND/ALTER unchanged, existing unit tests pass, HA soak clean, xce_alloc_mode_t extension point |
seqjob_allocator, xce_sched_ctx_t.alloc_mode |
job_alloc_fn (seqjob), xce_alloc_mode_t enum |
Combinatorial Allocation sequence (seqjob path = 1-bucket case) |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
xce_alloc_plan_iter_t (interface) |
Scheduler / Allocation | Lazy per-allocator plan generation | 2.1–2.6 | xce_primary_resreq_t (P0) |
Service |
host_iter.c |
Scheduler / Allocation | Shared lazy host-candidate filtering | 3.1–3.6 | select-expression evaluator (P0), resource.c (P0) |
Service |
parjob_allocator |
Scheduler / Plugin | Real combinatorial multi-host allocation | 4.1–4.7 | xce_alloc_plan_iter_t (P0), host_iter.c (P0), resource.c (P0) |
Service |
seqjob_allocator |
Scheduler / Plugin | Trivial single-host allocation, behavior-preserving rewrap | 9.1, 4.6 | xce_alloc_plan_iter_t (P0), host_iter.c (P0) |
Service |
alloc_cache.c |
Scheduler / Allocation | Session-scoped unsatisfiable-resreq memoization | 7.1–7.5 | kv.c (P0), core_session.c (P0) |
Service, State |
xce_alloc_mode_t extension point |
Scheduler / Allocation | Forward-compatible resource-pool-mode surface | 9.5 | none (v1: XCE_ALLOC_NORMAL only) |
State |
Scheduler / Allocation
xce_alloc_plan_iter_t
| Field | Detail |
|---|---|
| Intent | Lazy, cancel-safe generation of candidate (bucket_slots[]) partitions, most-preferred first, one per allocator |
| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5, 2.6 |
Responsibilities & Constraints
- Each concrete implementation (parjob's combinatorial, seqjob's trivial) owns its own plan-shape and partition policy; the interface itself is policy-agnostic.
- v1's combinatorial default (ascending host-count, canonical balanced ceil/floor partition, descending bucket order) is fully specified in
requirements.md's "Data Structure Changes Summary" — a pure function ofmaxTasks, never inspecting live worker state. - Stack-allocated by the caller, initialized in place (
iter.init(&iter, resreq, sched)) — never a heap-allocating factory (matchessched_ctx_init(&ctx, ...)'s existing convention).
Dependencies
- Inbound:
parjob_allocator,seqjob_allocator(P0, sole consumers) - Outbound:
xce_primary_resreq_t(P0, read-only —minTasks/maxTasksused in v1;span/same/affinityread by future allocators without an interface change)
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
struct xce_alloc_plan_iter_s {
int (*init)(xce_alloc_plan_iter_t* iter, xce_primary_resreq_t* resreq, xce_scheduler_t* sched);
xce_alloc_plan_t* (*next)(xce_alloc_plan_iter_t* iter);
void (*fini)(xce_alloc_plan_iter_t* iter);
void* private;
};
- Preconditions:
resreq != NULL;init()called exactly once before anynext(). - Postconditions:
next()returns candidate plans strictly in most-preferred-first order until exhaustion (stableNULLthereafter);fini()releases all iterator-owned resources. - Invariants: the pointer
next()returns is valid only until the followingnext()orfini()call;next()callspthread_testcancel()(or equivalent) at least once per invocation (Req 2.6).
host_iter.c
| Field | Detail |
|---|---|
| Intent | The one shared xce_host_iter_t implementation, filtering candidate hosts by select expression + resource availability for a given plan |
| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 |
Responsibilities & Constraints
- One implementation, reused by every allocator — no allocator-specific host-filtering logic anywhere else.
- Scoped per plan bucket, not per plan: a multi-bucket plan requires a fresh
xce_host_iter_tinstance for each bucket (init()called once per bucket), never one instance shared across a plan's buckets — seerequirements.md's Requirement 3.3/3.4 for why a shared, forward-only cursor is an incorrect implementation (it silently drops a host an earlier, larger bucket rejected even when it exactly satisfies a later, smaller one). - Host exclusivity across buckets of the same plan (
is_claimed()) is the caller's responsibility, not this component's — this component only ever answers "does this select expression + availability filter match," with no memory of prior buckets.
Dependencies
- Inbound:
parjob_allocator,seqjob_allocator(P0) - Outbound: existing select-expression evaluator (
resreq.h,select_eval_ctx_t) (P0, unchanged);resource.c'ssched_check_host_satisfy()(P0, unchanged)
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
/* Correction (found during task 1.1 implementation): next() returns
* xce_sched_worker_t* (sched/types.h -- resvals/resInUse/host_eval_ctx),
* not xce_worker_t* (xce/types.h -- the wire/config type make_sched_worker()
* converts FROM). requirements.md's own Composition Algorithm pseudocode
* already used xce_sched_worker_t*; this struct declaration was out of
* sync with it. */
struct xce_host_iter_s {
int (*init)(xce_host_iter_t* iter, xce_alloc_plan_t* plan, var workers);
xce_sched_worker_t* (*next)(xce_host_iter_t* iter);
void (*fini)(xce_host_iter_t* iter);
void* private;
};
- Preconditions:
plan != NULL; a fresh instance per bucket (see above). - Postconditions:
next()returnsNULLonce no further candidate exists for this instance's bucket search; a fresh instance for the next bucket is unaffected. - Invariants: independent lifecycle from the owning
xce_alloc_plan_iter_t—fini()on one never affects the other;next()callspthread_testcancel()(or equivalent) at least once per invocation (Req 3.6).
Implementation Notes
- Integration: constructed fresh inside the per-bucket loop of both
parjob_allocator's andseqjob_allocator'smy_alloc()— seerequirements.md's "Composition Algorithm" for the exact loop shape. - Validation:
test_host_iter.cmust include the exact adversarial case that motivated the fresh-per-bucket requirement (a host too small for an earlier, larger bucket remaining eligible for a later, smaller one). - Risks: none new — this is the shared implementation of behavior
seqjob_allocatoralready has today (sched_collect_workers()+xlist_find()), generalized.
alloc_cache.c
| Field | Detail |
|---|---|
| Intent | Session-scoped memoization of resreqs this session already proved unsatisfiable, keyed by a composite fingerprint |
| Requirements | 7.1, 7.2, 7.3, 7.4, 7.5 |
Responsibilities & Constraints
- Owns
xce_cache_key_t,xce_resreq_fingerprint_t, andcache_key_ops(a thirdkv_key_ops_tinstance alongsideint_key_ops/string_key_ops). Full field-by-field hash/cmp/assign/destroy implementations are inrequirements.md's "New types (Req 7)" section — corrected during design-phase discovery (the.cmpreturn-value polarity was inverted in an earlier draft; fixed to the real three-way, 0-means-equal conventionkv.c'sint_key_ops.cmpestablishes). - Lookup/insert happen exclusively on the allocator dispatch path (
sched_make_primary_alloc_(), wrapping either allocator's call) — never insideparjob_allocator.c/seqjob_allocator.cthemselves, so both allocators benefit identically without duplicated cache-handling code. - Clear happens exactly once, inside
core_begin_session_internal()(core_session.c), alongside the existingcore_reset_shared_resval()/core_reset_workers()calls — no new lock, reusingxce_core_begin_session()'s existing happens-before guarantee (see Existing Architecture Analysis above). - Not part of the snapshot/replay wire format — pure in-memory, unconditionally cleared at the next
begin_sessionregardless of what a loaded snapshot contained.
Dependencies
- Inbound:
scheduler.c'ssched_make_primary_alloc_()(P0, sole caller of lookup/insert);core_session.c'score_begin_session_internal()(P0, sole caller of clear) - Outbound:
kv.c'skvtab_type/key_find/key_add(P0);core_events.c'score_build_schedobj()(P0, populatesresreq_fpon ingest);resreq.c'sparse_resreq_primary()(P0, populatesrusage_hash)
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [x]
Service Interface
/* Correction (kiro-validate-design pass): a bare hit/miss sentinel cannot
* satisfy Req 7 AC 5 ("same pend reason category" on a hit) -- the value
* stored per key is the original miss's reason CATEGORY (an int code,
* e.g. xce_job_preason_t.main_reason), not a placeholder. Not the full
* detailed reason object: that carries job/host-specific breakdown
* (xce_host_reason_t*/xce_res_reason_t* arrays) that wouldn't transfer
* meaningfully to a different job sharing the same fingerprint. */
int cache_lookup(xce_scheduler_t* sched, xce_sched_obj_t* obj, int* out_reason_category);
/* 1 = hit (out_reason_category set), 0 = miss */
int cache_insert(xce_scheduler_t* sched, xce_sched_obj_t* obj, int reason_category);
/* 0 = ok, <0 = error */
void cache_clear(xce_scheduler_t* sched); /* called from core_begin_session_internal() */
- Preconditions:
obj->tag == XCE_RESREQ_PRIMARY;obj->u.primary->resreq_fpalready populated (job-ingest time);cache_insert()'sreason_categoryis the exhausted plan iterator's own final pend reason'smain_reason. - Postconditions:
cache_lookup()returning a hit means the caller pends the job using*out_reason_category(Req 7.5) and never invokes the plan/host iterators or preemption for this attempt. - Invariants: cache is cleared exactly once per scheduling session, strictly before the picker/allocator threads touch it for that session.
State Management
- State model: one
kvtab_tperxce_scheduler_t, keyed byxce_cache_key_t(composingxce_resreq_fingerprint_t+{user, queue, project}xstring pointers), value is the reason-categoryintboxed as(void*)(intptr_t)category(no heap allocation needed for a single int;NULLdatakiller, matchingint_key_ops's own value-handling convention). - Persistence & consistency: none — explicitly not snapshot-captured (see Boundary Commitments).
- Concurrency strategy: no explicit lock; safety derives from
xce_core_begin_session()'s documented ordering guarantee plus lookup/insert being confined to the single-threaded allocator path.
Implementation Notes
- Integration:
cache_key_cmp_()'s three-way-comparator fix (this design pass) must land alongside the rest ofalloc_cache.c— a functionally dead cache from day one would not fail any test that doesn't specifically assert a hit, sotest_alloc_cache.cmust include a same-key-twice hit assertion, not only a miss-then-insert-then-lookup round-trip that could pass even with the inverted polarity if written carelessly. - Validation:
test_alloc_cache.cmust also assert the reason-category round-trip (insert with category X, hit later returns X) — the value-model fix from this validation pass. - Risks:
.cmppolarity bug already fixed (seeresearch.md); the reason-category round-trip is the one new surface this fix adds, worth its own explicit test rather than assuming the hit/miss path alone proves it.
Scheduler / Plugin
parjob_allocator
| Field | Detail |
|---|---|
| Intent | Real combinatorial multi-host, multi-slot job allocation |
| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7 |
Responsibilities & Constraints
my_alloc()walks its own combinatorial plan iterator; for each plan, loops buckets, each bucket getting a freshhost_iter.cinstance (host-exclusivity viais_claimed(), not iterator sharing).- The per-plan
claimed[]accumulator (tracking which host was claimed for each bucket, for both the merge step and rollback) is sized byprimary_resreq->maxTasks— the true, tight upper bound on bucket count (each bucket needs ≥1 slot, sonbucketscan never exceed it) — not a fixed compile-time constant an implementer would otherwise have to guess (kiro-validate-designfinding, corrected inrequirements.md's pseudocode). - On full-plan success: merges claimed hosts into one
xce_job_alloc_tvia the newxcealloc.chelper (runtime-sized array, notmake_host_allocs()'s variadic signature). - On per-bucket failure: rolls back via
resource.c's newsched_ctx_uncommit_host(), scoped toctx->uncomittedonly (never touches realresInUse). - On full exhaustion: returns
0, identical to today's stub return value, sosched_make_primary_alloc_()'s pend-reason handling is unaffected (Req 4.4). parjob_prepare_allocation()setsctx->alloc_reqviacalc_wanted_host_resvals(..., ctx->nhosts, ctx->nslots), reading the current bucket's slot count fromctx->nslots(set by the bucket loop before each host try) rather than a hardcoded constant.- Shared-resource satisfaction is checked once per primary resreq, independent of plan/bucket shape — unchanged from today's mechanism (Req 4.7).
Dependencies
- Inbound:
sched_make_primary_alloc_()(P0) - Outbound:
xce_alloc_plan_iter_t(own combinatorial impl, P0),host_iter.c(P0),resource.c(sched_check_host_satisfy,sched_ctx_uncommit_host, P0),xcealloc.c(merge helper, P0)
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
static int my_alloc(xce_sched_plugin_t* plugin, xce_sched_ctx_t* ctx); /* job_alloc_fn */
static int parjob_prepare_allocation(void* arg, xce_sched_ctx_t* ctx); /* prepare_allocation_hook */
- Preconditions:
ctx->primary->maxTasks > 1(routed here bysched_make_primary_alloc_()). - Postconditions: return
>0andctx->allocpopulated on success; return0withctx->reasonpopulated on exhaustion — same contract shapeseqjob_allocator'smy_alloc()already has. - Invariants: never claims the same host twice within one plan attempt; never leaves a phantom
ctx->uncomitteddebit after a failed plan attempt.
Implementation Notes
- Integration: registration in the plugin framework (
register_parjob_allocator()) is unchanged — only the two hook bodies change. - Validation:
test_parjob_combinatorial.cmust cover the plan-order-matters worked example fromrequirements.md(descending bucket order succeeding where ascending would fail, identical host pool) and the fresh-per-buckethost_iterrequirement. - Risks: the
seqjob_prepare_allocation()fan-out corruption this fix depends on (Req 4.6) — mitigated by landing both changes together, never one without the other (seeresearch.mdRisks).
seqjob_allocator
| Field | Detail |
|---|---|
| Intent | Trivial single-host allocation, rewrapped onto the shared iterator pattern with provably unchanged external behavior |
| Requirements | 9.1, 4.6 |
Responsibilities & Constraints
my_alloc()'s bucket loop degenerates to exactly one iteration (nbuckets == 1,bucket_slots == [1]), using the same sharedhost_iter.candis_claimed()/rollback machineryparjob_allocatoruses — the abstraction validated on the simple case before the complex one.seqjob_prepare_allocation()gains aprimary->maxTasks == 1guard — a no-op for seqjob's own behavior (it never seesmaxTasks > 1jobs), required only so it stops corruptingparjob_allocator's fixed prepare hook viaplugin_prepare_allocation()'s fan-out.
Dependencies
- Inbound:
sched_make_primary_alloc_()(P0) - Outbound:
xce_alloc_plan_iter_t(own trivial impl, P0),host_iter.c(P0)
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
Same shape as parjob_allocator's (my_alloc/prepare_allocation_hook) — see above; not restated.
- Preconditions:
ctx->primary->maxTasks == 1. - Postconditions: identical host selection to today's
sched_collect_workers()+xlist_find()implementation, given identical worker/resource state (Req 9.1) — this is the requirement the rewrap must prove, not just aim for. - Invariants: unchanged.
Implementation Notes
- Integration: registration (
register_seqjob_allocator()) unchanged. - Validation:
test_seqjob_rewrap_equivalence.cmust construct fixture states where the rewrap could plausibly diverge (multiple candidate hosts, at least one failing the select filter) and assert identical outcomes pre/post rewrap — a test that only exercises the single-obvious-host case would not actually prove equivalence. - Risks: none beyond the shared
seqjob_prepare_allocation()guard dependency already noted underparjob_allocator.
Scheduler / Allocation (extension point)
xce_alloc_mode_t
| Field | Detail |
|---|---|
| Intent | Forward-compatible resource-pool-mode field on xce_sched_ctx_t, declared now so scheduler-preemption (and later modes) don't need to modify this spec's struct later |
| Requirements | 9.5 |
Responsibilities & Constraints
- v1 declares
XCE_ALLOC_NORMAL = 0andXCE_ALLOC_PREEMPT; onlyXCE_ALLOC_NORMALbehavior is implemented — no branch anywhere in this spec'stry_alloc_on_host()/availability-check call sites reads this field for any purpose beyond existing. - Adding further enum values or the actual borrowing behavior
XCE_ALLOC_PREEMPTimplies is explicitlyscheduler-preemption's job, not this spec's.
Contracts: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [x]
State Management
- State model: a single
xce_alloc_mode_t alloc_modefield onxce_sched_ctx_t, defaulting toXCE_ALLOC_NORMAL(zero-value, matchessched_ctx_init()'s existingmemset(ctx, 0, sizeof(*ctx))). - Persistence & consistency: none —
xce_sched_ctx_tis per-allocation-attempt scratch state, never persisted (seerequirements.md's "Rollback scope" discussion of the same struct). - Concurrency strategy: none needed — single-threaded, per-attempt scratch.
Data Models
Domain Model
xce_alloc_plan_t: a partition of a job'smaxTasksinto per-host slot buckets (nbuckets,bucket_slots[]) — the domain concept this whole feature revolves around. Not persisted; lives only for the duration of one plan attempt.xce_resreq_fingerprint_t/xce_cache_key_t: value objects, immutable once computed, fitting in a single cache line — seerequirements.md's exact field layout, not restated here.
Logical Data Model
No new persistent entities. The only durable-adjacent addition is rusage_hash on xce_primary_resreq_t (in-memory, process-lifetime, set once at parse time) and resreq_fp on xce_primary_schedobj_t (same lifetime class) — both scratch/derived fields, never wire-serialized, confirmed in requirements.md's Requirement 7 notes and this spec's own Boundary Commitments.
Data Contracts & Integration
None — this feature introduces no new wire format, no new capnp schema, and (per the explicit design decision in requirements.md) no snapshot/replay format changes for the cache.
Error Handling
Error Strategy
Allocation failure is not an error condition in this domain — it is the expected, common "no plan/host satisfies this resreq right now" outcome, communicated via return value (0) and ctx->reason, exactly as today. Genuine errors (OOM, iterator init() failure) propagate as negative return codes and are treated the same as "no candidate for this bucket" at the allocation-decision level — a distinction requirements.md's Composition Algorithm draws explicitly (host-iterator init failure treated as an infrastructure error, not a resource-availability determination).
Error Categories and Responses
- Resource unavailable (expected, high-frequency): plan exhausted → pend job with reason; cache the fingerprint so subsequent identically-shaped jobs skip the cost (Req 7).
- Iterator init failure (rare, e.g. OOM): treated as "this bucket/plan has no candidates," not a distinct error path — avoids a second failure taxonomy the caller would need to handle.
- Fan-out corruption (latent, closed by this design):
seqjob_prepare_allocation()'s missingmaxTasksguard, closed by Req 4.6 — see Existing Architecture Analysis.
Monitoring
No new logging/metrics surface beyond what ctx->reason/xce_pendreason_t already provides — this feature populates the same pend-reason machinery every allocator already uses, adding no new observability contract.
Testing Strategy
- Unit — plan iterator (
test_alloc_plan_iter.c, Req 2): one-plan-per-next()-call; most-preferred-first ordering formaxTasksvalues with and without a clean partition (e.g.N=7→[3,2,2]fork=3); stable exhaustion signal;fini()cleanup;pthread_testcancel()present. - Unit — host iterator (
test_host_iter.c, Req 3): select-expression + availability filtering unchanged from the existing evaluator; the fresh-per-bucket adversarial case (host too small for an earlier bucket remains eligible for a later, smaller one) — this is the single most important test in this spec, directly proving the bug found during design discussion is closed. - Unit — cache (
test_alloc_cache.c, Req 7): hit on identical fingerprint+identity; miss on any differing field; same-key-twice hit (not just insert-then-miss-different-key, which the inverted.cmpbug would still have passed); reason-category round-trip (insert with category X, later hit returns X, per Req 7.5 — thekiro-validate-designfix to the cache's value model); clear-at-begin-session. - Unit — parjob combinatorial (
test_parjob_combinatorial.c, Req 4): multi-bucket plan success and partial-failure-then-rollback; descending-vs-ascending bucket order worked example;ctx->nslotscorrectly varies the resource want per bucket. - Unit — seqjob rewrap equivalence (
test_seqjob_rewrap_equivalence.c, Req 9.1): identical host selection pre/post rewrap across fixture states including a failing-select-filter host, not just the single-obvious-host trivial case. - Integration — end-to-end multi-slot allocation (
test_parjob_e2e_multislot.c, brief.md scope): a real multi-host parallel job dispatched through the fullsched_make_primary_alloc_()→parjob_allocator→ commit path, asserting the finalxce_job_alloc_t.nhosts > 1and correct per-host slot counts. - Regression — existing scheduler suite (Req 9.3): full
schedulermeson suite must pass unmodified in assertions, adjusted only where a test directly exercised the eager-one-shot or stub-return behavior this feature replaces. - HA soak (Req 9.4): sequential and chaotic
/xce-test-haregression, confirming zero new job loss/divergence/liveness-stall attributable to this feature.
Supporting References
- Full pseudocode (composition algorithm, cache key hash/cmp/assign/destroy, v1 plan-shape/partition defaults) lives in
requirements.md— not duplicated here per this template's own deduplication guidance.design.md's job is component/file ownership and contracts;requirements.mdremains the algorithmic source of truth. research.md— discovery findings (the.cmppolarity fix, the core-isolation boundary check, the test-file convention) and the three most consequential design decisions with alternatives considered.