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>
36 KiB
Design Document: scheduler-allocator-runner
Overview
Purpose: The scheduler framework takes ownership of the job-allocation algorithm. One framework routine, sched_allocator_run(), performs the shared-resource precheck, the plan → bucket → host claim search with rollback, and result assembly for every allocator plugin. An allocator plugin supplies only a candidate-plan iterator, an optional acceptance guard, and its existing prepare_allocation_hook.
Users: Scheduler plugin authors (a new allocator needs only a plan iterator) and scheduler maintainers (an allocation fix lands once).
Impact: Deletes my_alloc() from seqjob_allocator.c and parjob_allocator.c and parjob's claim helpers; adds alloc_runner.c; adds an allocator iface to the plugin ABI; retypes the plan iterator's init third parameter from xce_scheduler_t* to xce_sched_core_t*. Allocation decisions, host choice, main pend reason and result codes are unchanged for every input that reaches the allocators today. The intentional changes are the four listed in requirements.md → Introduction; the side effects of the per-candidate reset are under System Flows → Consequences.
Goals
- One allocation algorithm, used by seqjob and parjob in normal and simulation mode (1.1, 6.3).
- Behavioural equivalence proven by characterization tests written against the current code before it is removed (7.2).
issues/new_requirements.mditem 4 closed (7.4).
Non-Goals
- A per-plugin result-assembly (
commit) hook. - Retyping
sched_make_allocs()or removingplugin->sched. - Merging
sched_make_seqjob_allocation()/sched_make_parjob_allocation()dispatch. - Adding an allocation duration (the
FIXME: no duration nowstays). - Fixing bug-1892 (
unsatisfy_()reads uncommitted amounts by position). - Publishing pend reasons only when they change (follow-up to the larger per-host reason sets of 3.7).
Boundary Commitments
This Spec Owns
xce_sched_allocator_iface_t(shape, ownership, lifetime) and theallocator_ifacefield ofxce_sched_plugin_t.sched_allocator_run()and its internal steps: input/acceptance guards, shared-resource precheck (incl. the bug-1893 loop-bound fix), claim search and rollback, per-candidatealloc_req.hostreset andnslots/nhostsassignment, result assembly, cleanup of every attempt-owned resource.- The
xce_alloc_plan_iter_t.initparameter type. - The seqjob/parjob plugin wiring (templates, iface instances,
parjob_accepts) and the removal of their private allocation code. - Equivalence and runner tests; the pend-reason factory audit's file list;
issues/new_requirements.mditem 4.
Out of Boundary
- Dispatch (
scheduler.csched_make_primary_alloc_()and the COMPOUND/ALTER callers): routing bymaxTasks, the unsatisfiable cache, result merging, session stamping. resource.csatisfy checks (sched_check_shared_satisfy,sched_check_host_satisfy,unsatisfy_),sched_try_alloc_on_host_(),plugin_prepare_allocation(), the host iterator, and both plugins'prepare_allocation_hooks: called unchanged.- The plan iterators' plan sequences (only the parameter type changes).
sched_make_allocs(),make_*_allocconstructors in libxce,sim_alloc.cregistration (comment updates only).- Snapshot, WAL, recording formats: nothing persisted changes.
Allowed Dependencies
alloc_runner.c→resource.c(sched_try_alloc_on_host_,sched_check_shared_satisfy,sched_ctx_uncommit_host),schedobj.c(sched_ctx_get_uncomit_shared),host_iter.c(sched_host_iter_*),scheduler.c(sched_make_allocsonly), libxce (make_pendreason,add_job_preason,make_host_alloc,make_job_alloc,free_*,xvec_*).- Plugins →
alloc_runner.conly throughsched_allocator_runand the iface type. alloc_runner.cmust not reference any plugin file or plugin-specific symbol; plugins must not call runner internals.
Revalidation Triggers
- Any change to
xce_sched_allocator_iface_torxce_alloc_plan_iter_tsignatures (all allocator plugins,test_parjob_combinatorial.c,test_alloc_plan_iter.c,test_alloc_runner.c's fake iface). - Any change to the order of guard → precheck → search → assembly, or to when
ctx->reason/ctx->allocare set (dispatch, unsatisfiable cache, COMPOUND merge,scheduler-alter-pend-reasons). - Fixing bug-1892 (changes COMPOUND admission; re-run COMPOUND characterization tests).
- A new allocator plugin (must supply a plan iterator whose buckets are ≤ its task count).
Architecture
Existing Architecture Analysis
sched_make_primary_alloc_() picks core->seqjob_allocator (maxTasks == 1) or core->parjob_allocator and calls plugin->job_alloc_fn(plugin, ctx). Each plugin's my_alloc() then repeats the whole algorithm. Plan generation already sits behind xce_alloc_plan_iter_t, host scanning behind xce_host_iter_t, per-host preparation behind plugin_prepare_allocation(). The picker side already uses a framework-owned iface (picker_iface) with a forward typedef in plugin.h.
Architecture Pattern & Boundary Map
Template method: the framework owns the algorithm; the plugin fills in plan generation.
graph TB
Dispatch[scheduler dispatch] --> JobAllocFn[plugin job_alloc_fn]
JobAllocFn --> Runner[sched_allocator_run]
Runner --> Iface[allocator iface]
Iface --> SeqPlans[seqjob plan iterator]
Iface --> ParPlans[parjob plan iterator]
Iface --> ParAccepts[parjob accepts]
Runner --> SharedCheck[resource shared check]
Runner --> HostIter[host iterator]
Runner --> TryHost[try alloc on host]
TryHost --> Prepare[plugin prepare allocation]
Prepare --> SeqPrep[seqjob prepare hook]
Prepare --> ParPrep[parjob prepare hook]
Runner --> MakeAllocs[sched make allocs]
- Dependency direction: libxce →
resource.c/schedobj.c/host_iter.c→alloc_runner.c→ plugins (via iface data only) → dispatch. Plugins referencesched_allocator_runby address in their template; the runner never names a plugin. One sanctioned exception: the runner callsscheduler.c'ssched_make_allocs()(a leftward call into the dispatch file), exactly as bothmy_alloc()s do today; removing it is the out-of-boundarysched_make_allocs()retype follow-up. No otherscheduler.csymbol may be used. - Existing patterns preserved: static iface storage referenced from the plugin struct (picker); white-box test
#includeof.cfiles;sched_make_allocs()stubbed in white-box tests. - Steering compliance: new file under the 800-line cap; plugins stay under
src/plugins/; no I/O, no locks, no blocking added (allocator thread only).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Scheduler daemon | C (gnu17), Meson | Runner, iface, plugin wiring | No new dependency |
| Tests | Unity, white-box #include |
Characterization + runner tests | Existing convention |
File Structure Plan
Directory Structure
src/scheduler/
├── include/
│ ├── plugin.h # + forward typedef xce_sched_allocator_iface_t; + allocator_iface field
│ └── sched/alloc_iter.h # plan iterator init retyped; + xce_sched_allocator_iface_t; + sched_allocator_run prototype
├── src/
│ ├── alloc_runner.c # NEW: sched_allocator_run and its static steps
│ ├── sim_alloc.c # comments only (my_alloc references)
│ └── plugins/
│ ├── seqjob_allocator.c # - my_alloc; + seqjob_alloc_iface; template -> sched_allocator_run
│ └── parjob_allocator.c # - my_alloc, claim helpers; + parjob_accepts, parjob_alloc_iface
├── meson.build # + src/alloc_runner.c in scheduler_srcs
└── testsuite/
├── test_alloc_runner.c # NEW: runner tests with an in-test fake plan iterator
├── test_alloc_equivalence_seqjob.c # NEW: seqjob characterization tests via job_alloc_fn
├── test_alloc_equivalence_parjob.c # NEW: parjob characterization tests via job_alloc_fn
└── meson.build # + three targets
Modified Files
src/scheduler/include/sched/alloc_iter.h—init's third parameter becomesxce_sched_core_t* core; definesxce_sched_allocator_iface_tand declaressched_allocator_run.src/scheduler/include/plugin.h— forward typedef andconst xce_sched_allocator_iface_t* allocator_iface;inxce_sched_plugin_t, documented with the same ownership note aspicker_iface.src/scheduler/src/plugins/seqjob_allocator.c— plan-iteratorinitsignature; deletemy_alloc(); addstatic const xce_sched_allocator_iface_t seqjob_alloc_iface(accepts = NULL); template.job_alloc_fn = sched_allocator_run,.allocator_iface = &seqjob_alloc_iface; update comments.src/scheduler/src/plugins/parjob_allocator.c— same, plusparjob_accepts()(maxTasks > 1); deleteparjob_claim_t,parjob_is_claimed_,parjob_snapshot_host_req_,parjob_rollback_claims_,parjob_try_plan_.src/scheduler/src/sim_alloc.c— comment text only.src/scheduler/meson.build— addsrc/alloc_runner.c.src/scheduler/testsuite/test_seqjob_allocator.c,test_seqjob_rewrap_equivalence.c,test_parjob_combinatorial.c— add#include "../src/alloc_runner.c"; replace directmy_alloc()/parjob_try_plan_()/parjob_rollback_claims_()calls withsched_allocator_run()/sched_try_plan_()/sched_rollback_claims_(); plan-iteratorinitthird argument becomes a core pointer.src/scheduler/testsuite/test_alloc_plan_iter.c— itsstub_plan_iter_initthird parameter becomesxce_sched_core_t*(plan-iterator retype).src/scheduler/testsuite/test_picker_pendreason_factory_audit.c— addsrc/alloc_runner.cto the scanned list.src/scheduler/testsuite/meson.build— three new targets.issues/new_requirements.md— item 4 marked resolved.
System Flows
flowchart TD
Start[sched_allocator_run] --> Inputs{inputs present}
Inputs -->|no| EInval[return EINVAL]
Inputs -->|yes| Accept{accepts}
Accept -->|rejected| Pend0[return 0]
Accept -->|accepted or NULL| Reason[ensure pend reason record]
Reason --> Shared{shared request met}
Shared -->|error| Err[return error]
Shared -->|no| SharedReasons[one reason per unmet entry then return 0]
Shared -->|yes| Uncommit[count shared as uncommitted]
Uncommit --> Init{plan init ok}
Init -->|no| Pend1[return 0]
Init -->|yes| Next{next plan}
Next -->|none| Fini0[fini and return 0]
Next -->|plan| Grow{claims buffer fits}
Grow -->|grow fails| FiniOom[fini and return ENOMEM]
Grow -->|malformed plan| Next
Grow -->|ok| TryPlan[try plan]
TryPlan -->|bucket unsatisfied| Next
TryPlan -->|snapshot OOM| FiniErr[fini and return error]
TryPlan -->|all buckets claimed| Finalize[build result]
Finalize -->|OOM| FiniOom2[fini and return ENOMEM]
Finalize -->|ok| Done[publish ctx alloc then fini and return 1]
Try plan (per bucket, in order): reset alloc_req.host to empty → new host iterator → for each candidate: skip if already claimed in this plan (and reset alloc_req.host again after every rejected candidate, before the next next()); set nslots = bucket_slots[b], nhosts = 1; sched_try_alloc_on_host_() > 0 → snapshot alloc_req.host into the claim (snapshot OOM: uncommit the candidate with the live request, roll back earlier claims, return the error) → next bucket. A bucket with no host rolls back every claim of the plan and returns 0.
Key decisions: the reset and the nslots/nhosts assignment are unconditional (brief items 1 and 3); every return after plan_init succeeded calls plan_fini and frees the claims buffer; claim snapshots are freed by whichever function owns them at that point (see AllocRunner → State Management).
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | One routine for both plugins | AllocRunner, PluginWiring | sched_allocator_run |
Start |
| 1.2 | Plugin supplies plan iterator, accepts, prepare hook only | AllocatorIface | xce_sched_allocator_iface_t |
— |
| 1.3 | NULL accepts = accept all | AllocRunner | accepts |
Accept |
| 1.4 | Rejected = pending, no reason, no checks | AllocRunner | accepts |
Accept → Pend0 |
| 1.5 | No private allocation routine in plugins | PluginWiring | — | — |
| 1.6 | Iface static, not freed, plugin lifetime | AllocatorIface, PluginWiring | allocator_iface |
— |
| 2.1 | Reason record created after guards | AllocRunner (ensure_reason) |
— | Reason |
| 2.2 | One reason per unmet entry (bug-1893) | AllocRunner (prepare_shared) |
— | SharedReasons |
| 2.3 | Shared counted once, not released | AllocRunner (prepare_shared) |
— | Uncommit |
| 2.4 | Prep failures return their error | AllocRunner | — | Err |
| 3.1 | Plans in generator order, stop at first satisfied | AllocRunner | plan_next |
Next |
| 3.2 | Buckets in order, first unclaimed satisfying host | ClaimSearch | sched_host_iter_* |
TryPlan |
| 3.3 | Roll back claims on unsatisfied bucket | ClaimSearch | sched_ctx_uncommit_host |
TryPlan |
| 3.4 | Init failure / no plan = pending | AllocRunner | plan_init |
Pend1, Fini0 |
| 3.5 | nslots = bucket slots, nhosts = 1 |
ClaimSearch | — | TryPlan |
| 3.6 | Empty per-host request before each candidate | ClaimSearch | — | TryPlan |
| 3.7 | Per-host reasons complete, both allocators | ClaimSearch (via 3.6) | sched_try_alloc_on_host_ |
TryPlan |
| 3.8 | No fixed bucket limit | AllocRunner (claims buffer) | — | Grow |
| 4.1 | One host alloc per claim, claim order, snapshot request | ResultAssembly | sched_make_allocs, make_host_alloc |
Finalize |
| 4.2 | Shared record per shared-request entry | ResultAssembly | sched_make_allocs |
Finalize |
| 4.3 | jobid 0, empty reserve, no duration | ResultAssembly | make_job_alloc(0, …) |
Finalize |
| 4.4 | Satisfied → set alloc, return 1 | AllocRunner | — | Done |
| 4.5 | Results identical to current allocators | ResultAssembly, EquivalenceTests | — | — |
| 5.1 | Missing inputs → EINVAL, no reason | AllocRunner | — | EInval |
| 5.2 | parjob maxTasks <= 1 → pending |
ParjobAccepts | parjob_accepts |
Pend0 |
| 5.3 | seqjob accepts all | PluginWiring (accepts = NULL) |
— | Accept |
| 5.4 | OOM in result → ENOMEM, nothing left | ResultAssembly | — | FiniOom2 |
| 5.5 | OOM releases unconverted claims + failed candidate | ClaimSearch, ResultAssembly | sched_rollback_claims_ |
FiniErr, FiniOom2 |
| 5.6 | ctx->alloc set only on success |
ResultAssembly | — | Done |
| 5.7 | Fini iterator and free snapshots on every exit | AllocRunner | plan_fini |
all Fini nodes |
| 5.8 | Size from each primary's own request | AllocRunner (uses ctx->primary / ctx->alloc_req only) |
— | — |
| 6.1 | Plan init takes core | PlanIterContract | init(iter, resreq, core) |
Init |
| 6.2 | Identical plan sequences | PlanIterContract, EquivalenceTests | — | — |
| 6.3 | Sim uses the same routine, no scheduler object to plan iterators | PluginWiring, PlanIterContract | — | — |
| 7.1 | Existing tests pass, limited test edits | TestMigration | — | — |
| 7.2 | Equivalence tests | EquivalenceTests, RunnerTests | — | — |
| 7.3 | Factory audit covers runner | TestMigration | — | — |
| 7.4 | Item 4 resolved | Closure | — | — |
| 7.5 | Invariant audit clean | Closure | — | — |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| AllocatorIface | Plugin ABI (alloc_iter.h, plugin.h) |
Declares what an allocator plugin supplies | 1.2, 1.3, 1.6 | — | Service |
| PlanIterContract | Plugin ABI (alloc_iter.h) |
Plan iterator init takes the core |
6.1, 6.2, 6.3 | — | Service |
| AllocRunner | Framework (alloc_runner.c) |
Guards, shared precheck, plan loop, cleanup | 1.1, 1.3, 1.4, 2.x, 3.1, 3.4, 3.8, 4.4, 5.1, 5.7, 5.8 | resource.c (P0), schedobj.c (P0) | Service, State |
| ClaimSearch | Framework (alloc_runner.c) |
Bucket → host claim, snapshot, rollback | 3.2, 3.3, 3.5, 3.6, 3.7, 5.5 | host_iter.c (P0), resource.c (P0) | Service |
| ResultAssembly | Framework (alloc_runner.c) |
Build and publish xce_job_alloc_t |
4.1–4.5, 5.4–5.6 | sched_make_allocs (P0), libxce (P0) |
Service |
| PluginWiring | Plugins (seqjob_allocator.c, parjob_allocator.c) |
Templates + iface instances; delete private code | 1.1, 1.5, 1.6, 5.2, 5.3, 6.3 | AllocRunner (P0) | — |
| ParjobAccepts | Plugin (parjob_allocator.c) |
maxTasks > 1 guard |
5.2 | — | Service |
| EquivalenceTests / RunnerTests / TestMigration / Closure | Tests, docs | Evidence and closure | 4.5, 6.2, 7.1–7.5 | — | — |
Plugin ABI
AllocatorIface and PlanIterContract
| Field | Detail |
|---|---|
| Intent | The allocator plugin contract and the retyped plan-iterator initialiser |
| Requirements | 1.2, 1.3, 1.6, 6.1, 6.2, 6.3 |
Contracts: Service [x]
Service Interface
/* include/sched/alloc_iter.h */
struct xce_alloc_plan_iter_s {
int (*init)(xce_alloc_plan_iter_t* iter, xce_primary_resreq_t* resreq,
xce_sched_core_t* core); /* was xce_scheduler_t* */
xce_alloc_plan_t* (*next)(xce_alloc_plan_iter_t* iter);
void (*fini)(xce_alloc_plan_iter_t* iter);
void* private;
};
struct xce_sched_allocator_iface_s {
/* Optional. 1 = this plugin allocates `primary`, 0 = not (pending, no
* reason, no checks). NULL = accept every request. */
int (*accepts)(const xce_primary_resreq_t* primary);
/* Required. Same contract as xce_alloc_plan_iter_t init/next/fini. */
int (*plan_init)(xce_alloc_plan_iter_t* iter, xce_primary_resreq_t* resreq,
xce_sched_core_t* core);
xce_alloc_plan_t* (*plan_next)(xce_alloc_plan_iter_t* iter);
void (*plan_fini)(xce_alloc_plan_iter_t* iter);
};
/* job_alloc_fn for every allocator plugin; `plugin` is xce_sched_plugin_t*.
* > 0 satisfied (ctx->alloc set), 0 pending, < 0 negative errno. */
extern int sched_allocator_run(void* plugin, xce_sched_ctx_t* ctx);
/* include/plugin.h */
typedef struct xce_sched_allocator_iface_s xce_sched_allocator_iface_t;
/* in xce_sched_plugin_t: static storage owned by the plugin file; the
* registry never frees it (same ownership as picker_iface). NULL for
* non-allocator plugins. */
const xce_sched_allocator_iface_t* allocator_iface;
- Preconditions: for an allocator plugin,
allocator_ifaceand its threeplan_*members are non-NULL; every plan'snbuckets >= 1and eachbucket_slots[i] >= 1. - Postconditions:
plan_initreturns 0 and leavesiterready forplan_next, or non-zero with nothing for the runner to release (the runner then skipsplan_fini). It may read the core; both built-in iterators ignore it. - Invariants: the iface is
static constin the plugin file; plugin templates copy the pointer on registration (1.6).
Implementation Notes
- Integration: seqjob/parjob iterator functions and
test_alloc_plan_iter.c's stub iterator change only their third parameter's type;test_parjob_combinatorial.c's poisoned-scheduler case becomes a poisoned-core case (6.2 evidence: plan sequences equal withNULLand poisoned core). - Risks: a plugin that sets
job_alloc_fn = sched_allocator_runwithout an iface gets-EINVAL(5.1), not a crash.
Framework runner (src/scheduler/src/alloc_runner.c)
AllocRunner
| Field | Detail |
|---|---|
| Intent | The one allocation routine |
| Requirements | 1.1, 1.3, 1.4, 2.1–2.4, 3.1, 3.4, 3.8, 4.4, 5.1, 5.7, 5.8 |
Responsibilities & Constraints
- Order: (1) input guard:
plugin,ctx,plugin->core,plugin->sched,ctx->primary,plugin->allocator_ifaceand itsplan_*members non-NULL, else-EINVALbefore touchingctx->reason(5.1); (2)acceptsif non-NULL, reject → 0 (1.3, 1.4); (3)ensure_reason: createmake_pendreason(0)ifctx->reason == NULL,-ENOMEMon failure (2.1, 2.4); (4)prepare_shared(below); (5)plan_init(iter, ctx->primary, plugin->core), non-zero → 0 withoutplan_fini(3.4, as today); (6) plan loop; (7) cleanup. prepare_shared:sched_ctx_get_uncomit_shared;xvec_idval_fini(&ctx->unmeet);sched_check_shared_satisfy(core, uncomit, &ctx->alloc_req.shared, &ctx->unmeet);< 0→ return it;== 0→ fori < ctx->unmeet.count:vget(resmgr, unmeet.idxs[i]), skip only onrc < 0(vgetreturns 0 on success; today'src <= 0skips every hit, bug-1893),add_job_preason(reason, PREASON_NO_RESOURCE, NULL, r->name); return 0 (2.2);> 0→xvec_idval(uncomit, &ctx->alloc_req.shared, XVEC_ADD_FLOAT)once, error → return it (2.3, 2.4). Shared amounts are never subtracted by the runner (2.3).- Claims buffer:
sched_alloc_claim_t* claims(heap), capacity grown withrealloctoplan->nbucketsbefore a plan is tried; growth failure →plan_fini, free,-ENOMEM(3.8). No claim is outstanding between plans. - Uses only
ctx->primaryandctx->alloc_reqfor sizing (5.8). - Precondition
ctx->alloc == NULLon entry (the dispatch and COMPOUND/ALTER callers move or free any previous result before the next attempt); the runner never frees or overwrites a non-NULLctx->allocexcept with its own result on success. - A plan with
nbuckets < 1or anybucket_slots[i] < 1violates the iterator precondition; the runner skips it as unsatisfied (built-in iterators never produce one, so this changes no current outcome).
Contracts: Service [x] / State [x]
Service Interface
int sched_allocator_run(void* plugin, xce_sched_ctx_t* ctx);
/* static, reachable from white-box tests */
static int sched_alloc_ensure_reason_(xce_sched_ctx_t* ctx); /* 0 | -ENOMEM */
static int sched_alloc_prepare_shared_(xce_sched_core_t* core,
xce_sched_ctx_t* ctx); /* 1 met | 0 pend | <0 */
State Management
- Attempt-owned: plan iterator private state and the claims buffer, released by the runner on every return path after
plan_initsucceeded (5.7). Claim snapshots are owned by exactly one function at a time:sched_try_plan_()creates them and frees them itself on 0 /< 0; on 1 it hands all of them tosched_finalize_job_alloc_(), which frees every one on every path. The runner itself never frees a snapshot, so no path can free one twice. - Thread cancellation:
plan_next(both iterators) andsched_host_iter_next()arepthread_testcancel()points, and the allocator thread is cancelled only at shutdown (scheduler.c:905,sim.c:727). A cancel inside an attempt abandons the attempt-scoped heap (iterator state, host-iterator state, claim snapshots, and now the claims buffer) exactly as today; the runner adds nopthread_cleanup_push()handlers, consistent with the rest of the scheduler, which has none. "Every return" in 5.7 means every return path, not an asynchronous cancel. - Context-owned (not released by the runner):
ctx->reason, shared uncommitted amounts, per-host debits of claims already converted into host allocations (5.5 wording),ctx->allocon success.
ClaimSearch
| Field | Detail |
|---|---|
| Intent | Try one plan: claim a distinct host per bucket, or roll back |
| Requirements | 3.2, 3.3, 3.5, 3.6, 3.7, 5.5 |
Service Interface
typedef struct {
xce_sched_worker_t* w;
xvec_idval_t host_req; /* deep copy of ctx->alloc_req.host at claim */
} sched_alloc_claim_t;
/* 1 all buckets claimed (claims[0..*nclaimed) owned by caller)
* 0 a bucket had no host (all claims rolled back, snapshots freed)
* <0 snapshot failure (candidate + earlier claims rolled back, snapshots freed) */
static int sched_try_plan_(xce_sched_core_t* core, xce_sched_ctx_t* ctx,
xce_alloc_plan_t* plan, sched_alloc_claim_t* claims,
int* nclaimed);
static void sched_rollback_claims_(xce_sched_ctx_t* ctx,
sched_alloc_claim_t* claims, int n);
static int sched_snapshot_host_req_(xvec_idval_t* dst, const xvec_idval_t* src);
- Preconditions:
claimshas capacity>= plan->nbuckets. - Postconditions: on 0 or
< 0, every per-host debit made in this call is released (3.3, 5.5) and no snapshot remains. - Behaviour is parjob's current
parjob_try_plan_()withplan->ctx = ctxand the per-candidatenslots/nhostsassignment (3.5), applied to every plugin, and one change:alloc_req.hostis cleared at the start of each bucket AND after every rejected candidate, i.e. before everyhost_iter->next()call (3.6). Clearing only once per bucket (parjob today) is not enough: a rejected candidate leaves its own per-host request inalloc_req.host, and the host iterator's pre-filter (host_iter.c) would then skip later short hosts silently. With the request always empty whennext()pre-filters, the pre-filter does select-expression filtering only, every selection-matching unclaimed host reachessched_try_alloc_on_host_(), and each short one gets its reasons (3.7).
ResultAssembly
| Field | Detail |
|---|---|
| Intent | Turn claims into the published xce_job_alloc_t |
| Requirements | 4.1–4.5, 5.4, 5.5, 5.6 |
Service Interface
/* 1: ctx->alloc set; -ENOMEM: ctx->alloc untouched, everything built freed.
* Consumes claims: each snapshot is freed once its host alloc is built;
* on failure, claims not yet converted are rolled back and freed. */
static int sched_finalize_job_alloc_(xce_scheduler_t* sched, xce_sched_ctx_t* ctx,
sched_alloc_claim_t* claims, int nclaimed);
- Steps, matching parjob's current tail:
calloc(nclaimed)host-alloc array (failure: roll back all claims); per claimsched_make_allocs(sched, "", nres, idxs, vals, NULL)(/* FIXME: no duration now */kept) thenmake_host_alloc(w->name, nres, allocs), wherenres == 0allows a NULLallocs(failure: freeallocs, roll back claims[i, n),free_HostAlloc_list(nclaimed, hallocs));make_job_alloc(0, nclaimed, hallocs)into a local (failure:free_HostAlloc_list); shared recordssched_make_allocs(sched, "", shared.count, …, NULL)whenalloc_req.shared.count > 0, elseallocs_shared = NULL, nres_shared = 0(failure:free_JobAlloc_ptr(&local)); thenctx->alloc = local(5.6). schedis(xce_scheduler_t*)plugin->sched(the stand-in wrapper in sim), used only forsched_make_allocs()(out-of-boundary retype).- Equivalence note (4.5): seqjob today builds its one-element host array with
make_host_allocs(1, halloc); the runner'scalloc+ store produces the same array, freed by the samefree_HostAlloc_list.
Plugins
PluginWiring and ParjobAccepts
| Field | Detail |
|---|---|
| Intent | Register both plugins on the runner |
| Requirements | 1.1, 1.5, 1.6, 5.2, 5.3, 6.3 |
/* parjob_allocator.c */
static int parjob_accepts(const xce_primary_resreq_t* primary)
{ return primary->maxTasks > 1; } /* bug-1767 */
static const xce_sched_allocator_iface_t parjob_alloc_iface = {
.accepts = parjob_accepts, .plan_init = parjob_plan_iter_init,
.plan_next = parjob_plan_iter_next, .plan_fini = parjob_plan_iter_fini };
/* seqjob_allocator.c */
static const xce_sched_allocator_iface_t seqjob_alloc_iface = {
.accepts = NULL, .plan_init = seqjob_plan_iter_init,
.plan_next = seqjob_plan_iter_next, .plan_fini = seqjob_plan_iter_fini };
/* both templates */
.job_alloc_fn = (plugin_job_allocate_fn_t)sched_allocator_run,
.allocator_iface = &<plugin>_alloc_iface,
prepare_allocation_hook,init_fn,fini_fn,*_new_plugin(),register_*()unchanged; sim registration insim_alloc.ctherefore reaches the runner unchanged (6.3).
Consequences of the per-candidate reset (3.6, both allocators)
Because the host iterator's pre-filter no longer skips hosts on stale state, a pending attempt of either allocator now runs sched_try_alloc_on_host_(), and so the full plugin_prepare_allocation() fan-out, on every selection-matching unclaimed host that is short of resources. Today that happens only for the first rejected candidate of each bucket search (parjob) or for candidates that pass the stale filter (seqjob). Besides the complete per-host pend reasons (3.7), this means:
- Limit plugin side effects (
limit.cmatch_job_host_limit_): for a host with a matching host-based limit it allocates the job'sobj->hlimits[worker]list, may instantiate the limit's virtual resource inresmgr(instantiate_limit) and writes the limit value intoworker->resvals. This is decision-neutral: any later check of that host by any job runs the same lazy instantiation before reading the value, so it only moves when the instance is created. It happens identically in normal and simulation mode (driver state-equivalence holds). - Cost: one prepare fan-out plus satisfy check per short host per bucket search, where the stale pre-filter used to reject later hosts with a single satisfy check. Repeated attempts for the same unsatisfiable request are still short-circuited by the unsatisfiable cache before the allocator runs (
scheduler.c:248). - Pend-reason volume: a pending job can now carry one reason per short host (per bucket search for parjob). These reasons are published to the controller and persisted with the job. Publishing reasons only when they change is a recorded follow-up (Non-Goals), not part of this spec.
Data Models
No persisted or wire data changes. In-memory only: sched_alloc_claim_t (attempt-scoped) and the allocator_iface pointer (static). Snapshot/WAL/recording field-completeness is not affected (scheduler audit dimension 4: no change).
Error Handling
Error Strategy
The runner follows the allocator contract: > 0 satisfied, 0 pending, < 0 negative errno, early return on each error (project C convention), with cleanup of attempt-owned state on every path after plan_init.
| Condition | Result | State left behind |
|---|---|---|
| Missing plugin/ctx/core/sched/primary/iface | -EINVAL |
nothing; ctx->reason untouched |
accepts rejects |
0 | nothing |
make_pendreason fails |
-ENOMEM |
ctx->reason == NULL |
| Shared check error / uncommit add error | that error | reason record |
| Shared unmet | 0 | reason record with one NO_RESOURCE per unmet entry |
plan_init fails |
0 | reason record, shared uncommitted |
| No plan satisfied | 0 | reason record (+ host reasons), shared uncommitted |
Plan with nbuckets < 1 or a bucket of < 1 slots |
plan skipped as unsatisfied | nothing from that plan |
| Claims buffer growth fails | -ENOMEM |
as above; no host debit outstanding |
| Snapshot fails | its error (-ENOMEM) |
candidate and earlier claims released |
| Host alloc build fails | -ENOMEM |
unconverted claims released; converted ones' debits stay in ctx (as today) |
| Job alloc / shared records fail | -ENOMEM |
ctx->alloc untouched; debits stay in ctx (as today) |
Monitoring
Existing TRACE lines in resource.c are unchanged; the runner adds none (the allocator path runs per candidate and is already traced there).
Testing Strategy
TDD order follows the staged migration (research.md): characterization tests are written and pass against the current code first, then must pass unchanged after each switch.
Unit Tests
test_alloc_runner.c(white-box#includeofcore.c,resource.c,schedobj.c,host_iter.c,alloc_runner.c; stubbedsched_make_allocswith OOM injection; stubbedplugin_prepare_allocation()that recordsctx->nslots/ctx->nhosts/ctx->alloc_req.host.counton each call and stages a fixed per-host request, the same link-stub conventiontest_resource_accounting.cuses; an in-test fake iface whose plans are scripted):- guards: each missing input →
-EINVAL,ctx->reasonstill NULL (5.1); rejectingaccepts→ 0, no reason,plan_initnot called (1.4); NULLacceptsreachesplan_init(1.3). - shared: two shared resources, one short → exactly one host-less
NO_RESOURCEreason naming it (2.2, bug-1893); met → uncommitted added once across a 3-plan attempt (2.3). - search: plan order and first-satisfied stop (3.1); distinct hosts per bucket (3.2); failing bucket 2 rolls back bucket 1's debit (
wuncommitted back to 0) (3.3);nslots/nhostsseen by a recording prepare hook equalbucket_slots[b]/1 (3.5);alloc_req.hostempty before every candidate's pre-filter: with three short hosts in one bucket, all three get host/resource reasons (today only the first would) (3.6, 3.7); a plan with more buckets than a first small plan triggers buffer growth (3.8). - errors: snapshot OOM, host-alloc OOM at claim 0 and claim k, job-alloc OOM, shared-records OOM →
-ENOMEM,ctx->alloc == NULL, released debits as in the Error table, ASAN clean (5.4, 5.5, 5.6, 5.7).
- guards: each missing input →
test_parjob_combinatorial.c: plan sequences identical forNULLand poisoned core (6.2).
Equivalence (characterization) Tests
test_alloc_equivalence_seqjob.c/test_alloc_equivalence_parjob.c(white-box#includeofcore.c,resource.c,schedobj.c,host_iter.c, the plugin file, and from its switch stepalloc_runner.c;sched_make_allocsstubbed with OOM injection;plugin_prepare_allocation()stubbed by the existing convention oftest_seqjob_allocator.c:84— resetalloc_req.sharedor.hostlikeplugin.c, then call the plugin's own prepare hook — so limit fill is not exercised here; limits stay covered through the realplugin.cbytest_sim_alloc_limit_fanout): drive the real plugin throughplugin->job_alloc_fnon fixed fixtures (success; pending on shared; pending on hosts; parjob mid-plan rollback; parjobmaxTasks0 and 1; seqjob withmaxTasks0/2 via direct call; COMPOUND second primary with non-overlapping resources so bug-1892 is not exercised). Assert result code, host count/names, per-host resource entries/values, shared records, per-host and shared uncommitted debits, and pend reasons. Written and passing against today's code first (step 2). The fixtures for the intentional changes that the current code can run (seqjob per-host reasons, 3.7; bug-1893, 2.2; seqjob OOM rollback, 5.5) assert the new expected values: they are added at the start of the step that switches that plugin (RED against the not-yet-switched plugin) and turn GREEN within that step, so no step ends with a failing test (4.5, 7.2). seqjob's missing-input fixtures (5.1) are added after the switch only: today's seqjob dereferences the missing pointer, so they cannot fail as an assertion first;test_alloc_runner.ccovers the same guard with the fake iface before the switch.
Regression (existing)
- The Req 7.1 list, unchanged in expected outcomes:
test_seqjob_allocator,test_seqjob_rewrap_equivalence,test_parjob_combinatorial,test_parjob_e2e_multislot,test_maxtasks_wiring_*,test_scheduler_cache_dispatch*,test_sim_contention_*parity,test_sim_live_allocation,test_sim_alloc_limit_fanout,test_sched_election_sim_fixture,test_snapshot_load_allocator_regression;test_picker_pendreason_factory_auditwith the new file listed (7.3). Full/xce-build scheduler(ASAN) suite green.
Invariant audit
/xce-audit-invariants schedulerafter the switch (7.5). Dimensions checked at design time: (1) driver state-equivalence — the runner is reached identically fromdriver.candsim_loop.cvia the same plugins; (2) core I/O isolation —alloc_runner.cdoes no I/O; (3) picker→allocator rendezvous — untouched; (4) snapshot parity — no persisted field changes.
Migration Strategy
- Retype plan-iterator
init(header, both iterators,test_parjob_combinatorial.c,test_alloc_plan_iter.c). - Characterization tests against current code (pass).
- Add
alloc_runner.c, iface,plugin.hfield; switch parjob; delete parjob's private code; update white-box tests. - Switch seqjob; delete
my_alloc(); update white-box tests. - Closure: comments in both plugins and
sim_alloc.c, factory audit list,issues/new_requirements.mditem 4, invariant audit.
Each step ends with the full scheduler suite green; the only RED tests within a step are that step's own intentional-change fixtures, and a failing step-2 characterization test at steps 3–4 blocks the step.