diff --git a/.kiro/specs/scheduler-allocation/tasks.md b/.kiro/specs/scheduler-allocation/tasks.md index 2bafeb70..5275855b 100644 --- a/.kiro/specs/scheduler-allocation/tasks.md +++ b/.kiro/specs/scheduler-allocation/tasks.md @@ -1,7 +1,7 @@ # Implementation Plan - [ ] 1. Foundation: allocation data types and extension points -- [ ] 1.1 Add the lazy plan-generation and host-candidate iterator contracts +- [x] 1.1 Add the lazy plan-generation and host-candidate iterator contracts - Define the plan representation as a partition of a job's task count into per-host slot buckets, most-preferred ordering to be produced by whichever component generates plans - Define the plan-iterator and host-iterator contracts (initialize, produce next candidate, release resources) as reusable abstractions any allocator can adopt - Observable: the contracts compile and are referenced by at least a stub/no-op implementation with no allocator wired in yet @@ -103,3 +103,7 @@ - Observable: the soak reports zero new job loss, divergence, or liveness stall attributable to this feature - _Requirements: 9.4_ - _Depends: 6.1_ + +## Implementation Notes + +- (1.1) `xce_host_iter_t.next()` returns `xce_sched_worker_t*` (`sched/types.h`, carries `resvals`/`resInUse`/`host_eval_ctx`) — never `xce_worker_t*` (`xce/types.h`, the wire/config type `make_sched_worker()` converts FROM). requirements.md/design.md briefly had this wrong; fixed in commit `d1a3e158`. Task 2.1 (`host_iter.c`'s real implementation) and any later task touching worker candidates must use `xce_sched_worker_t*` from the start. diff --git a/src/scheduler/include/sched/alloc_iter.h b/src/scheduler/include/sched/alloc_iter.h new file mode 100644 index 00000000..70103e35 --- /dev/null +++ b/src/scheduler/include/sched/alloc_iter.h @@ -0,0 +1,140 @@ +#if !defined(_SCHED_ALLOC_ITER_H_) + +#define _SCHED_ALLOC_ITER_H_ 1 + +/* + * alloc_iter.h - lazy allocation-plan and host-candidate iterator + * contracts (scheduler-allocation spec, Req 2 AC 1-6 / Req 3 AC 1-6). + * + * This header defines two reusable abstractions any allocator plugin can + * adopt: + * + * xce_alloc_plan_iter_t - generates candidate allocation plans (a + * partition of a primary resreq's maxTasks into per-host slot buckets), + * one per next() call, most-preferred first. + * + * xce_host_iter_t - for a given plan, generates candidate hosts (one + * per next() call) matching the plan's select-expression + availability + * filter. + * + * No allocator is wired onto these interfaces yet -- see + * ../../testsuite/test_alloc_plan_iter.c for a trivial, no-op reference + * implementation of each proving the contract shape compiles and is + * usable. + */ + +#include + +#include "resreq.h" +#include "scheduler.h" +#include "xce/types.h" + +/* + * xce_alloc_plan_t - a candidate allocation plan: a partition of a + * primary resreq's maxTasks into per-host slot buckets, e.g. for + * maxTasks=3: [3], [2,1], [1,1,1] -- NOT a uniform (nhosts, + * slots_per_host) pair, which cannot express uneven splits. + * + * Produced by xce_alloc_plan_iter_t.next(); the returned pointer is valid + * only until the following next() or fini() call on that same iterator + * (it is not independently heap-owned by the caller). + */ +typedef struct { + int nbuckets; /* number of hosts this plan needs (== + * len(bucket_slots)) */ + int* bucket_slots; /* partition of maxTasks; bucket_slots[i] = slots + * needed on the i-th distinct host claimed for + * this plan */ + /* host_filter: derived from primary_resreq->select, plus slot + * availability -- consumed by xce_host_iter_t (Req 3), not exposed as + * a separate field here. This interface does not mandate a + * representation for it; each concrete plan-iterator implementation + * carries whatever it needs to reconstruct the filter. */ +} xce_alloc_plan_t; + +typedef struct xce_alloc_plan_iter_s xce_alloc_plan_iter_t; + +/* + * xce_alloc_plan_iter_t - lazy, cancel-safe candidate-plan generator (Req + * 2). One instance is owned by a single allocator's job_alloc_fn for the + * lifetime of one job's allocation attempt. + * + * Allocation convention: stack-allocated by the caller, then initialized + * IN PLACE -- init() takes the iterator's own address as its first + * argument, matching this codebase's existing sched_ctx_init(&ctx, ...) + * convention (schedobj.c). This is NOT a heap-allocating factory + * function: + * + * xce_alloc_plan_iter_t iter_storage; + * xce_alloc_plan_iter_t* iter = &iter_storage; + * xce_alloc_plan_t* plan; + * + * if (iter->init(iter, primary_resreq, sched) != 0) { ... } + * while ((plan = iter->next(iter)) != NULL) { ... } + * iter->fini(iter); + */ +struct xce_alloc_plan_iter_s { + /* Initialize the iterator for a given primary resreq and scheduler + * state. Returns 0 on success, nonzero on error. */ + int (*init)(xce_alloc_plan_iter_t* iter, xce_primary_resreq_t* resreq, + xce_scheduler_t* sched); + + /* Return the next candidate plan, or NULL when exhausted. Successive + * calls return plans ordered most-preferred to least-preferred, per + * the owning allocator's own preference policy; the NULL + * end-of-iteration signal is stable across repeated calls once + * exhausted. The returned plan pointer is valid until the next + * next() or fini() call. Calls pthread_testcancel() (or an + * equivalent real cancellation point) at least once per invocation. */ + xce_alloc_plan_t* (*next)(xce_alloc_plan_iter_t* iter); + + /* Release all resources held by the iterator. The interface defines + * no other required cleanup step. */ + void (*fini)(xce_alloc_plan_iter_t* iter); + + /* Private state: current position, strategy-specific data. */ + void* private; +}; + +typedef struct xce_host_iter_s xce_host_iter_t; + +/* + * xce_host_iter_t - lazy, cancel-safe candidate-host filter for one plan + * bucket (Req 3). Shares the same stack-allocate-then-init-in-place + * convention as xce_alloc_plan_iter_t above. + * + * Scoped PER PLAN BUCKET, not per plan: a multi-bucket plan requires a + * fresh xce_host_iter_t instance (a new init() call) for each bucket -- + * never one instance shared across a plan's buckets. next() is a + * forward-only cursor with no rewind; sharing one instance across buckets + * would permanently drop a host an earlier, larger bucket tried and + * rejected as too small, even when that same host exactly satisfies a + * later, smaller bucket. Host exclusivity ACROSS buckets of the same plan + * (is_claimed()) is the caller's responsibility, not this interface's -- + * this iterator only ever answers "does this select expression + + * availability filter match," with no memory of prior buckets. + */ +struct xce_host_iter_s { + /* Initialize the iterator for a given plan and worker set. Returns 0 + * on success, nonzero on error. */ + int (*init)(xce_host_iter_t* iter, xce_alloc_plan_t* plan, var workers); + + /* Return the next candidate worker matching the plan's host filter, + * or NULL when exhausted for this instance's bucket search -- + * exhaustion is scoped to this one instance, not to the plan as a + * whole (see the per-bucket-freshness note above: a plan with more + * buckets remaining requires a fresh host_iter instance). The + * returned worker pointer is valid until the next next() or fini() + * call. Calls pthread_testcancel() (or an equivalent real + * cancellation point) at least once per invocation. */ + xce_sched_worker_t* (*next)(xce_host_iter_t* iter); + + /* Release all resources held by the host iterator, independent of + * the owning plan iterator's lifecycle. */ + void (*fini)(xce_host_iter_t* iter); + + /* Private state: current position, filtering state. */ + void* private; +}; + +#endif diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index dfbd33cc..f086f0d2 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -1474,6 +1474,24 @@ sched_builtin_fcfs_cursor_uaf_test = executable( dependencies: [scheduler_test_deps], install: false) +# Contract test for the lazy allocation-plan and host-candidate iterator +# interfaces (scheduler-allocation spec, task 1.1, Req 2 AC 1-6 / Req 3 AC +# 1-6). Only #includes the new ../include/sched/alloc_iter.h -- no +# allocator is wired onto these interfaces yet (parjob_allocator/ +# seqjob_allocator rewrap is later tasks' own work). Proves the contract +# shape compiles and is usable via a trivial, no-op reference +# implementation of each interface, stack-allocated and initialized in +# place per the documented convention, driven through a full +# init()/next()/fini() cycle -- including the exact plan -> per-bucket +# fresh-host_iter composition shape requirements.md's Composition +# Algorithm describes for allocate_primary() (Req 3 AC 4 / Req 4 AC 2). +sched_alloc_plan_iter_test = executable( + 'sched_alloc_plan_iter_test', + ['test_alloc_plan_iter.c'], + include_directories:[incdir], + dependencies: [scheduler_test_deps], + install: false) + # Register as meson tests so `meson test --suite scheduler` runs them all. # detect_leaks=0 matches the suite convention (fixtures are not torn down). test('scheduler test', scheduler_test, suite: 'scheduler', @@ -1656,3 +1674,6 @@ test('scheduler limit plugin test', sched_limit_plugin_test, suite: 'scheduler', test('scheduler builtin fcfs cursor uaf test', sched_builtin_fcfs_cursor_uaf_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) +test('scheduler alloc plan iter test', + sched_alloc_plan_iter_test, suite: 'scheduler', + env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/scheduler/testsuite/test_alloc_plan_iter.c b/src/scheduler/testsuite/test_alloc_plan_iter.c new file mode 100644 index 00000000..aceabf78 --- /dev/null +++ b/src/scheduler/testsuite/test_alloc_plan_iter.c @@ -0,0 +1,316 @@ +/* + * test_alloc_plan_iter.c - contract test for the lazy allocation-plan and + * host-candidate iterator interfaces (scheduler-allocation spec, task + * 1.1, Req 2 AC 1 / Req 3 AC 1). + * + * This task only adds a header (sched/alloc_iter.h) declaring + * xce_alloc_plan_t/xce_alloc_plan_iter_t/xce_host_iter_t -- no allocator + * is wired onto them yet (parjob_allocator/seqjob_allocator rewrap is + * later tasks' work). So the RED/GREEN evidence for this task is: + * + * RED: before sched/alloc_iter.h existed, this file failed to compile + * (unknown types xce_alloc_plan_t/xce_alloc_plan_iter_t/ + * xce_host_iter_t). + * GREEN: with the header in place, a trivial, no-op reference + * implementation of each interface (below) can be constructed, + * stack-allocated and initialized in place per the documented + * convention, and driven through a full init()/next()/fini() + * cycle -- including the exact plan -> per-bucket host_iter + * composition shape requirements.md's Composition Algorithm + * describes -- proving the contract shape actually compiles and + * is usable, not just that a struct literal type-checks. + * + * Both stub implementations ignore the real xce_primary_resreq_t/ + * xce_scheduler_t/plan/workers arguments (ok, per the header's own + * "each concrete plan-iterator implementation carries whatever it needs" + * note) and always yield exactly one candidate before signaling stable + * exhaustion. + */ +#include +#include +#include +#include + +#include + +#include "sched/alloc_iter.h" + +/* ---- trivial no-op xce_alloc_plan_iter_t reference implementation ---- */ + +typedef struct { + int served; /* 0 = not yet served, 1 = exhausted */ + int bucket_slots[1]; + xce_alloc_plan_t plan; +} stub_plan_priv_t; + +static int stub_plan_iter_init(xce_alloc_plan_iter_t* iter, + xce_primary_resreq_t* resreq, + xce_scheduler_t* sched) +{ + stub_plan_priv_t* priv; + + /* Stub: ignores the real resreq/sched state -- a real implementation + * (parjob's combinatorial one, seqjob's trivial one) would derive its + * plan space from resreq->maxTasks here. */ + (void)resreq; + (void)sched; + + if (iter == NULL) { + return -EINVAL; + } + + priv = (stub_plan_priv_t*)calloc(1, sizeof(*priv)); + if (priv == NULL) { + return -ENOMEM; + } + + priv->served = 0; + priv->bucket_slots[0] = 1; + priv->plan.nbuckets = 1; + priv->plan.bucket_slots = priv->bucket_slots; + + iter->private = priv; + + return 0; +} + +static xce_alloc_plan_t* stub_plan_iter_next(xce_alloc_plan_iter_t* iter) +{ + stub_plan_priv_t* priv; + + /* Req 2 AC 6: a real cancellation point at least once per invocation + * -- combinatorial generation has none of its own. */ + pthread_testcancel(); + + if (iter == NULL) { + return NULL; + } + + priv = (stub_plan_priv_t*)iter->private; + if (priv == NULL || priv->served) { + return NULL; /* Req 2 AC 4: stable exhaustion signal */ + } + + priv->served = 1; + + return &priv->plan; +} + +static void stub_plan_iter_fini(xce_alloc_plan_iter_t* iter) +{ + if (iter == NULL) { + return; + } + + free(iter->private); + iter->private = NULL; +} + +/* ---- trivial no-op xce_host_iter_t reference implementation ---- */ + +typedef struct { + int served; /* 0 = not yet served, 1 = exhausted */ + xce_sched_worker_t worker; +} stub_host_priv_t; + +static int stub_host_iter_init(xce_host_iter_t* iter, xce_alloc_plan_t* plan, + var workers) +{ + stub_host_priv_t* priv; + + /* Stub: ignores the real plan/workers state -- the one shared real + * implementation (host_iter.c, Req 3) would filter `workers` by + * plan's select expression + slot availability here. */ + (void)plan; + (void)workers; + + if (iter == NULL) { + return -EINVAL; + } + + priv = (stub_host_priv_t*)calloc(1, sizeof(*priv)); + if (priv == NULL) { + return -ENOMEM; + } + + priv->served = 0; + priv->worker.name = "stub-host"; + + iter->private = priv; + + return 0; +} + +static xce_sched_worker_t* stub_host_iter_next(xce_host_iter_t* iter) +{ + stub_host_priv_t* priv; + + /* Req 3 AC 6: a real cancellation point at least once per invocation + * -- scanning/filtering has none of its own. */ + pthread_testcancel(); + + if (iter == NULL) { + return NULL; + } + + priv = (stub_host_priv_t*)iter->private; + if (priv == NULL || priv->served) { + return NULL; /* Req 3 AC 3: exhausted for this instance */ + } + + priv->served = 1; + + return &priv->worker; +} + +static void stub_host_iter_fini(xce_host_iter_t* iter) +{ + if (iter == NULL) { + return; + } + + free(iter->private); + iter->private = NULL; +} + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* + * Req 2 AC 1/2/4/5: the plan iterator is stack-allocated by the caller + * and initialized in place (not a heap-allocating factory -- see the + * header's own convention note), yields exactly one plan, then a stable + * NULL on every subsequent next() call, and fini() releases it cleanly. + */ +static void test_plan_iter_stub_produces_one_plan_then_stable_null(void) +{ + xce_alloc_plan_iter_t iter_storage; + xce_alloc_plan_iter_t* iter = &iter_storage; + xce_alloc_plan_t* plan; + + memset(iter, 0, sizeof(*iter)); + iter->init = stub_plan_iter_init; + iter->next = stub_plan_iter_next; + iter->fini = stub_plan_iter_fini; + + TEST_ASSERT_EQUAL_INT(0, iter->init(iter, NULL, NULL)); + + plan = iter->next(iter); + TEST_ASSERT_NOT_NULL(plan); + TEST_ASSERT_EQUAL_INT(1, plan->nbuckets); + TEST_ASSERT_NOT_NULL(plan->bucket_slots); + TEST_ASSERT_EQUAL_INT(1, plan->bucket_slots[0]); + + /* Req 2 AC 4: end-of-iteration signal, stable across repeated calls. */ + TEST_ASSERT_NULL(iter->next(iter)); + TEST_ASSERT_NULL(iter->next(iter)); + + iter->fini(iter); + TEST_ASSERT_NULL(iter->private); +} + +/* + * Req 3 AC 1/2/3/5: the host iterator is likewise stack-allocated and + * initialized in place from a plan, yields exactly one candidate host, + * then a stable NULL, and fini() is independent of the owning plan + * iterator's own lifecycle -- proved by finiing the host iterator first + * and then confirming plan_iter's own exhaustion/fini() sequence is + * completely unaffected. + */ +static void test_host_iter_stub_produces_one_worker_then_stable_null(void) +{ + xce_alloc_plan_iter_t plan_iter_storage; + xce_alloc_plan_iter_t* plan_iter = &plan_iter_storage; + xce_alloc_plan_t* plan; + xce_host_iter_t host_iter_storage; + xce_host_iter_t* host_iter = &host_iter_storage; + xce_sched_worker_t* w; + + memset(plan_iter, 0, sizeof(*plan_iter)); + plan_iter->init = stub_plan_iter_init; + plan_iter->next = stub_plan_iter_next; + plan_iter->fini = stub_plan_iter_fini; + + TEST_ASSERT_EQUAL_INT(0, plan_iter->init(plan_iter, NULL, NULL)); + plan = plan_iter->next(plan_iter); + TEST_ASSERT_NOT_NULL(plan); + + memset(host_iter, 0, sizeof(*host_iter)); + host_iter->init = stub_host_iter_init; + host_iter->next = stub_host_iter_next; + host_iter->fini = stub_host_iter_fini; + + /* `plan` is still valid here -- neither next() nor fini() has been + * called again on plan_iter since it was returned above, per the + * header's own "valid until the next next()/fini() call" contract. */ + TEST_ASSERT_EQUAL_INT(0, host_iter->init(host_iter, plan, NULL)); + + w = host_iter->next(host_iter); + TEST_ASSERT_NOT_NULL(w); + TEST_ASSERT_EQUAL_STRING("stub-host", w->name); + + /* Req 3 AC 3: exhausted for this instance's bucket search, stable. */ + TEST_ASSERT_NULL(host_iter->next(host_iter)); + TEST_ASSERT_NULL(host_iter->next(host_iter)); + + /* Req 3 AC 5: fini() on host_iter is independent of plan_iter's own + * lifecycle -- finiing it here must not disturb plan_iter, which is + * still fini()'d separately, afterward, below. */ + host_iter->fini(host_iter); + TEST_ASSERT_NULL(host_iter->private); + + /* plan_iter is unaffected by host_iter->fini() -- still stably + * exhausted (it already served its one plan above). Only now is its + * own storage (and the `plan` pointer into it) released. */ + TEST_ASSERT_NULL(plan_iter->next(plan_iter)); + plan_iter->fini(plan_iter); + TEST_ASSERT_NULL(plan_iter->private); +} + +/* + * Req 3 AC 4 / Req 4 AC 2 composition shape: a fresh xce_host_iter_t + * instance is constructed from the SAME plan once per bucket, each a + * fully independent instance -- the exact plan -> bucket -> host_iter + * nesting requirements.md's Composition Algorithm pseudocode describes + * for allocate_primary(), exercised here end to end against the stub + * implementations (a 2-bucket plan, each bucket getting its own fresh + * host_iter instance that is init()'d and fini()'d independently). + */ +static void test_fresh_host_iter_per_bucket_composes_with_one_plan(void) +{ + int bucket_slots[2] = { 2, 1 }; + xce_alloc_plan_t two_bucket_plan + = { .nbuckets = 2, .bucket_slots = bucket_slots }; + int b; + + for (b = 0; b < two_bucket_plan.nbuckets; b++) { + xce_host_iter_t host_iter_storage; + xce_host_iter_t* host_iter = &host_iter_storage; + xce_sched_worker_t* w; + + memset(host_iter, 0, sizeof(*host_iter)); + host_iter->init = stub_host_iter_init; + host_iter->next = stub_host_iter_next; + host_iter->fini = stub_host_iter_fini; + + /* A brand-new init() per bucket -- never one instance reused + * across the loop (Req 3's "fresh per bucket" requirement). */ + TEST_ASSERT_EQUAL_INT( + 0, host_iter->init(host_iter, &two_bucket_plan, NULL)); + + w = host_iter->next(host_iter); + TEST_ASSERT_NOT_NULL(w); + + host_iter->fini(host_iter); + TEST_ASSERT_NULL(host_iter->private); + } +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_plan_iter_stub_produces_one_plan_then_stable_null); + RUN_TEST(test_host_iter_stub_produces_one_worker_then_stable_null); + RUN_TEST(test_fresh_host_iter_per_bucket_composes_with_one_plan); + return UNITY_END(); +}