diff --git a/.kiro/specs/scheduler-allocation/tasks.md b/.kiro/specs/scheduler-allocation/tasks.md index 480215c8..0b5a0810 100644 --- a/.kiro/specs/scheduler-allocation/tasks.md +++ b/.kiro/specs/scheduler-allocation/tasks.md @@ -66,7 +66,7 @@ - Observable: a passing test suite covers multi-bucket success, the descending-vs-ascending worked example, and partial-failure-then-reversal - _Requirements: 4.2, 4.4, 4.5, 4.6, 4.7_ - _Depends: 3.1_ -- [ ] 4.3 Implement parjob's multi-host allocation commit +- [x] 4.3 Implement parjob's multi-host allocation commit - Merge every claimed bucket's host allocation into a single job allocation record spanning all claimed hosts, leaving job-level shared-resource allocation computed once and unaffected by how many hosts were used - Observable: a successfully allocated multi-host job's allocation record correctly lists every claimed host with its own slot count - _Requirements: 4.3_ diff --git a/src/scheduler/testsuite/test_parjob_combinatorial.c b/src/scheduler/testsuite/test_parjob_combinatorial.c index 3338ce55..e4717914 100644 --- a/src/scheduler/testsuite/test_parjob_combinatorial.c +++ b/src/scheduler/testsuite/test_parjob_combinatorial.c @@ -114,6 +114,17 @@ int plugin_register(void* sched, void* plugin) void plugin_unregister(void* arg) { (void)arg; } +/* g_job_level_prepare_calls - task 4.3 (Req 4 AC 7): counts how many times + * the fan-out stub immediately below is invoked with ctx->worker == NULL -- + * the job-level shared/global-resource prepare pass. requirements.md's Req + * 4 AC 7 requires this pass to fire exactly once per primary resreq, + * independent of which plan/bucket-shape is later tried -- this counter + * lets test_shared_resource_debited_once_regardless_of_host_count() below + * confirm that dispatch-count claim dynamically (not just by inspection) + * across a real multi-bucket success. Each test that reads it resets it + * first, so counts never leak across tests. */ +static int g_job_level_prepare_calls = 0; + /* Delegates to the real parjob_prepare_allocation() under test, standing * in for plugin_prepare_allocation()'s (plugin.c) real fan-out across * every registered plugin -- exactly one (parjob's own) in this file's @@ -124,6 +135,7 @@ int plugin_prepare_allocation(void* sched, xce_sched_ctx_t* ctx) xce_sched_plugin_t fake_plugin; if (ctx->worker == NULL) { + g_job_level_prepare_calls++; xvec_idval_fini(&(ctx->alloc_req.shared)); xvec_idval_init(&(ctx->alloc_req.shared), 8); } else { @@ -210,6 +222,28 @@ static xce_resource_t* register_slots_resource(var resmgr) return makeResource(resmgr, &res); } +/* register_global_resource - task 4.3 (Req 4 AC 7): like + * register_slots_resource() above, but flagged XCE_RESF_GLOBAL (xce/ + * types.h) -- the scheduler's job-level/shared-resource pool + * (sched->core.sharedInst/sharedInUse), distinct from a per-host resource + * like "slots". resreq.c's calc_wanted_global_resvals()/ + * calc_wanted_host_resvals() dispatch on exactly this flag: a GLOBAL + * resource is only ever computed by the ctx->worker == NULL job-level + * prepare pass, never by the per-host pass. makeResource()'s own + * makeInstance()->resobj_init() memcpy()s the whole xce_resource_t + * (including .flags) into the resmgr-owned copy, so setting it here is + * sufficient. */ +static xce_resource_t* register_global_resource(var resmgr, const char* name) +{ + xce_resource_t res; + + memset(&res, 0, sizeof(res)); + res.name = strdup(name); + res.flags = XCE_RESF_GLOBAL; + + return makeResource(resmgr, &res); +} + /* add_worker_with_slots - a real sched_add_worker() (core.c) worker * carrying `slots_total` free "slots" (idx == the resmgr-assigned index), * identical to test_seqjob_allocator.c's own helper. */ @@ -963,6 +997,143 @@ static void test_returns_zero_when_every_plan_is_exhausted(void) sched_ctx_fini(&ctx); } +/* + * Task 4.3, Req 4 AC 7's own previously-unverified clause: "leaving + * job-level shared-resource allocation computed once and unaffected by how + * many hosts were used." Task 4.2's fixtures only ever used a per-host + * ("slots") resource -- this test drives a job carrying BOTH a per-host + * resource AND a genuinely job-level/shared (XCE_RESF_GLOBAL) resource + * through a real multi-host my_alloc() success and asserts the shared + * resource is debited exactly ONCE at the job level, not once per claimed + * host/bucket. + * + * The shared rusage uses PER_JOB semantics deliberately, not PER_TASK: + * calc_wanted_global_resvals()'s PER_TASK case scales by ctx->nslots, and + * since a plan's bucket_slots[] always sum to maxTasks by construction, a + * (hypothetical, buggy) per-bucket-repeated PER_TASK computation would sum + * to the SAME total as a correct once-only computation -- masking exactly + * the defect this test exists to catch. PER_JOB's flat value has no such + * coincidence: calc_wanted_global_resvals()'s `case PER_JOB: val = + * v->value;` ignores nslots/nhosts entirely, so a mistaken per-bucket (or + * per-claimed-host) repeat of the job-level pass would total value*nbuckets + * instead of value. + * + * Fixture: 4 hosts, each with exactly 1 free "slots" -- forces k=1 ([4]), + * k=2 ([2,2]), and k=3 ([2,1,1]) to all fail (no single host, or any pair/ + * triple, can supply a bucket needing >=2 slots) and k=4 ([1,1,1,1]) to be + * the only plan that succeeds, claiming ALL 4 hosts. This is a wider + * multi-host spread than task 4.2's existing 2-3 host tests, so this test + * also independently re-proves this task's OTHER Observable clause ("every + * claimed host with its own slot count") without relying on 4.2's own + * tests remaining unchanged. + * + * g_job_level_prepare_calls (the fan-out stub above) additionally proves, + * by direct dynamic count rather than code inspection alone, that the + * job-level (ctx->worker == NULL) prepare pass fires exactly once for this + * whole 4-host allocation attempt -- confirming sched_ctx_set_primary()'s + * single call site (schedobj.c), not my_alloc()'s plan/bucket loop, is what + * drives it. + */ +static void test_shared_resource_debited_once_regardless_of_host_count(void) +{ + xce_scheduler_t sched; + xce_resource_t* slots_res; + xce_resource_t* license_res; + xce_sched_worker_t* hosts[4]; + xce_sched_obj_t obj; + xce_primary_schedobj_t primary_obj; + xce_primary_resreq_t primary; + rusage_value_t license_rusage; + xce_sched_ctx_t ctx; + xvec_t* shared_uncomit = NULL; + int rc; + int i; + int idx[1]; + float val[1]; + char name[16]; + + memset(&sched, 0, sizeof(sched)); + TEST_ASSERT_EQUAL_INT(0, xce_sched_core_init(&(sched.core))); + + slots_res = register_slots_resource(sched.core.resmgr); + TEST_ASSERT_NOT_NULL(slots_res); + + license_res = register_global_resource(sched.core.resmgr, "licenses"); + TEST_ASSERT_NOT_NULL(license_res); + + /* Shared-pool total capacity for "licenses" -- far more than the 5.0 + * PER_JOB want below, so the shared-satisfy check trivially passes and + * the only thing under test is HOW MANY TIMES / HOW MUCH gets debited, + * not whether capacity exists. sched->core.sharedInUse is left at its + * zarray_init() default (nothing put), i.e. 0 currently in use. */ + idx[0] = license_res->idx; + val[0] = 100.0f; + put_resvals(&(sched.core.sharedInst), 1, idx, val); + + for (i = 0; i < 4; i++) { + snprintf(name, sizeof(name), "host-%d", i); + hosts[i] + = add_worker_with_slots(&(sched.core), name, slots_res->idx, 1.0f); + } + + memset(&primary, 0, sizeof(primary)); + primary.minTasks = 1; + primary.maxTasks = 4; + primary.select = NULL; + + memset(&license_rusage, 0, sizeof(license_rusage)); + license_rusage.res = strdup("licenses"); + license_rusage.value = 5.0f; + license_rusage.per_flag = PER_JOB; + + xlist_init(&(primary.rusages), 4); + xlist_store(&(primary.rusages), &license_rusage); + + init_primary_obj(&obj, &primary_obj, &primary); + + g_job_level_prepare_calls = 0; + rc = drive_my_alloc(&sched, &obj, &primary, &ctx); + + TEST_ASSERT_EQUAL_INT(1, rc); + TEST_ASSERT_NOT_NULL(ctx.alloc); + + /* This task's OTHER Observable clause, re-proved independently of + * task 4.2's own tests: every claimed host with its own slot count. */ + TEST_ASSERT_EQUAL_INT(4, ctx.alloc->nhosts); + for (i = 0; i < ctx.alloc->nhosts; i++) { + xce_host_alloc_t* ha = ctx.alloc->allocs[i]; + + TEST_ASSERT_NOT_NULL(ha); + TEST_ASSERT_EQUAL_INT(1, ha->nallocs); /* "slots" only */ + } + for (i = 0; i < 4; i++) { + assert_uncommitted_host_debit(&ctx, hosts[i], slots_res->idx, 1.0f); + } + + /* THE dispatch-count claim, confirmed dynamically: the job-level + * shared/global-resource prepare pass fired exactly once for this + * whole 4-host allocation attempt -- not once per claimed bucket/host. */ + TEST_ASSERT_EQUAL_INT(1, g_job_level_prepare_calls); + + /* THE property under test: the committed shared debit is the flat + * PER_JOB value exactly once (5.0) -- not multiplied by nhosts/ + * nbuckets (which a per-bucket-repeated commit would make 20.0). */ + rc = sched_ctx_get_uncomit_shared(&shared_uncomit, &ctx); + TEST_ASSERT_EQUAL_INT(1, rc); + TEST_ASSERT_NOT_NULL(shared_uncomit); + TEST_ASSERT_EQUAL_FLOAT(5.0f, + ((float*)shared_uncomit->data)[license_res->idx]); + + /* The final committed allocation record's own shared-resource fields + * mirror the same once-only amount, unaffected by nhosts. */ + TEST_ASSERT_EQUAL_INT(1, ctx.alloc->nres_shared); + TEST_ASSERT_NOT_NULL(ctx.alloc->allocs_shared); + + xlist_fini(&(primary.rusages)); + FREEUP(license_rusage.res); + sched_ctx_fini(&ctx); +} + void setUp(void) { return; } void tearDown(void) { return; } @@ -981,5 +1152,6 @@ int main(void) RUN_TEST( test_mid_plan_bucket_failure_rolls_back_before_later_plan_succeeds); RUN_TEST(test_returns_zero_when_every_plan_is_exhausted); + RUN_TEST(test_shared_resource_debited_once_regardless_of_host_count); return UNITY_END(); }