/* * test_parjob_combinatorial.c - tasks 4.1 + 4.2 (scheduler-allocation * spec): unit tests for parjob_allocator.c's REAL combinatorial * xce_alloc_plan_iter_t implementation (Req 2 AC 1-6, Req 4 AC 1) AND * (task 4.2, this pass) the real per-bucket allocation loop * (my_alloc()/parjob_try_plan_()/parjob_prepare_allocation(), Req 4 AC * 2/4/5/6/7). * * Task 4.1's own tests below never call my_alloc() -- they drive * parjob_plan_iter_init()/_next()/_fini() (static, hence the white-box * #include) directly, and needed no fixture beyond a bare * xce_primary_resreq_t, since the pre-4.2 my_alloc()/parjob_prepare_ * allocation() stub bodies made no calls into resource.c/schedobj.c/ * core.c/host_iter.c at all. Left completely unmodified below. * * Task 4.2 replaces that stub with a real implementation that DOES call * into those files (sched_try_alloc_on_host_(), sched_ctx_uncommit_host(), * sched_host_iter_init/next/fini(), sched_ctx_get_uncomit_shared(), * sched_check_shared_satisfy(), plugin_prepare_allocation()'s fan-out), * so this pass adds the SAME white-box #include combination * test_seqjob_allocator.c (task 3.1)/test_seqjob_rewrap_equivalence.c * (task 3.2) already established (core.c + resource.c + schedobj.c + * host_iter.c), plus the matching link-satisfying stubs (a * plugin_prepare_allocation() that delegates to the real * parjob_prepare_allocation() under test, and a sched_make_allocs() * stand-in that avoids white-box-including the rest of the 1382-line * scheduler.c) -- and a real fixture (registered "slots" resource, real * sched_add_worker() hosts) for the new bucket-loop tests. * * Covers (task 4.1, unchanged): * - one-plan-per-call generation (Req 2 AC 2) * - most-preferred-first ordering: ascending host-count k = 1..N * (Req 2 AC 3, Req 4 AC 1) * - the canonical balanced partition rule, including an uneven split * (N=7 across k=3 hosts -> [3,2,2]) (Req 4 AC 1) * - descending bucket order within one plan, by construction (Req 2 * AC 2's "not a uniform pair" + the Composition Algorithm's * descending-order correctness property) * - a stable end-of-iteration signal once k > N, stable across * repeated post-exhaustion calls (Req 2 AC 4) * - purity: plan shape/order is a pure function of maxTasks alone, * never inspecting live worker/scheduler state (Req 4 AC 1's "never * inspects live worker state" clause) -- proved by passing a NULL * xce_sched_core_t* to init() and confirming plans still generate * correctly * - fini() releases the iterator's private state (Req 2 AC 5) * - Req 2 AC 6: next() calls pthread_testcancel() (or an equivalent * real cancellation point) -- proved behaviorally, same technique * test_host_iter.c already established for host_iter_next(). * * Covers (task 4.2, new): * - multi-bucket success through the real my_alloc() end to end (Req * 4 AC 2/AC 3), including per-bucket resource want scaling with that * bucket's own slot count, not a fixed value (Req 4 AC 5/AC 6) * - the descending-vs-ascending worked example from requirements.md's * Composition Algorithm, driven directly through parjob_try_plan_() * with a hand-built plan for each order against the identical host * pool (Req 4 AC 2) * - a mid-plan bucket failure correctly reversing the earlier * successful bucket's provisional debit before the next candidate * plan is tried, proved by that same host being successfully * reclaimed by the later, winning plan (Req 4 AC 2, "Rollback scope") * - Req 4 AC 4: the same not-satisfied signal (0) once every plan is * exhausted */ #include #include #include #include #include #include #include #include "resobj.h" #include "resreq.h" #include "sched/alloc_iter.h" #include "scheduler.h" #include "xcealloc.h" /* Pull in the real xce_sched_core_init()/sched_add_worker() (core.c), * sched_meet_host_selection()/sched_check_host_satisfy()/ * sched_try_alloc_on_host_()/sched_ctx_uncommit_host()/put_resvals() * (resource.c), the real sched_ctx_init()/sched_ctx_get_uncomit_*() * (schedobj.c), the shared host_iter.c implementation, and * parjob_allocator.c itself (the file under test) into this TU -- same * combination test_seqjob_allocator.c (task 3.1) established for its own * sibling plugin. The plan-iterator and (new, task 4.2) bucket-loop * functions are all `static`, so this white-box #include is the only way * to reach them directly. */ #include "../src/core.c" #include "../src/host_iter.c" #include "../src/plugins/parjob_allocator.c" #include "../src/resource.c" #include "../src/schedobj.c" /* ---------------------------------------------------------------------- */ /* Link-satisfying stubs */ /* ---------------------------------------------------------------------- */ /* plugin_register()/plugin_unregister() are referenced by * register_parjob_allocator() (parjob_allocator.c) and core.c * respectively, but no test below calls register_parjob_allocator() -- * the plan iterator and my_alloc()/parjob_try_plan_() are exercised * directly. Never actually invoked by anything this file's tests * exercise; provided only so the TU links. */ int plugin_register(void* sched, void* plugin) { (void)sched; (void)plugin; return 0; } 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 * fixtures. Mirrors plugin.c:591's own reset-before-fan-out step, same * rationale as test_seqjob_allocator.c's identical stub. */ int plugin_prepare_allocation(xce_sched_core_t* 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 { xvec_idval_fini(&(ctx->alloc_req.host)); xvec_idval_init(&(ctx->alloc_req.host), 8); } memset(&fake_plugin, 0, sizeof(fake_plugin)); fake_plugin.sched = sched; /* scheduler-sim-live-allocation spec, task 4.3: parjob_prepare_ * allocation() now reads plugin->core (honestly typed, task 1.2) * instead of plugin->sched -- `sched` here is already an * xce_sched_core_t* (this stub's own signature), so no cast is * needed. */ fake_plugin.core = sched; return parjob_prepare_allocation(&fake_plugin, ctx); } int plugin_job_update(void* s, xce_sched_job_t* j) { (void)s; (void)j; return 0; } int plugin_queue_update(void* s, xce_sched_queue_t* q) { (void)s; (void)q; return 0; } int plugin_job_rusage_update(void* s, xce_sched_job_t* j, xce_job_rusages_t* r) { (void)s; (void)j; (void)r; return 0; } /* Avoids white-box-including the rest of scheduler.c just to build the * leaf xce_alloc_t records my_alloc()'s success path constructs -- same * rationale/shape as test_seqjob_allocator.c's identical stub. This * file's own quantitative per-host assertions go through * assert_uncommitted_host_debit() (real ctx->uncomitted state, untouched * by this stub) rather than these fake xce_alloc_t's fields, exactly like * the seqjob equivalence tests already do. */ /* bug-1882: arm to make the stub's Nth call (0-based) fail like the real * sched_make_allocs() does when make_alloc() cannot build a record. */ static int g_make_allocs_fail_at = -1; /* -1 == disabled */ static int g_make_allocs_calls = 0; xce_alloc_t** sched_make_allocs(xce_scheduler_t* sched, char* reserve, int nres, int* idxs, float* vals, int* duration) { xce_alloc_t** allocs; int i; (void)sched; (void)reserve; (void)idxs; (void)vals; (void)duration; if ((g_make_allocs_fail_at >= 0) && (g_make_allocs_calls++ == g_make_allocs_fail_at)) { errno = ENOMEM; return NULL; } if (nres <= 0) { return NULL; } allocs = (xce_alloc_t**)calloc(nres, sizeof(*allocs)); if (allocs == NULL) { return NULL; } for (i = 0; i < nres; i++) { allocs[i] = (xce_alloc_t*)calloc(1, sizeof(xce_alloc_t)); } return allocs; } /* ---------------------------------------------------------------------- */ /* Fixture helpers */ /* ---------------------------------------------------------------------- */ /* register_slots_resource - the real makeResource() (resobj.c), giving * "slots" a resmgr-assigned idx -- identical to test_seqjob_allocator.c's * own helper; parjob_prepare_allocation()'s key_find(resmgr, "slots",...) * call needs this to be a genuinely registered resource. */ static xce_resource_t* register_slots_resource(var resmgr) { xce_resource_t res; memset(&res, 0, sizeof(res)); res.name = strdup("slots"); 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. */ static xce_sched_worker_t* add_worker_with_slots(xce_sched_core_t* core, const char* name, int slots_idx, float slots_total) { xce_sched_worker_t* w; int idx[1]; float val[1]; int rc; w = (xce_sched_worker_t*)calloc(1, sizeof(*w)); TEST_ASSERT_NOT_NULL(w); w->name = strdup(name); TEST_ASSERT_NOT_NULL(w->name); rc = zarray_init(&(w->resvals), 8, NULL); TEST_ASSERT_EQUAL_INT(0, rc); rc = zarray_init(&(w->resInUse), 8, NULL); TEST_ASSERT_EQUAL_INT(0, rc); idx[0] = slots_idx; val[0] = slots_total; put_resvals(&(w->resvals), 1, idx, val); val[0] = 0.0f; put_resvals(&(w->resInUse), 1, idx, val); rc = sched_add_worker(core, w); TEST_ASSERT_TRUE(rc >= 0); return w; } static void init_primary_obj(xce_sched_obj_t* obj, xce_primary_schedobj_t* primary_obj, xce_primary_resreq_t* primary) { memset(primary_obj, 0, sizeof(*primary_obj)); primary_obj->resreq = primary; memset(obj, 0, sizeof(*obj)); obj->tag = XCE_RESREQ_PRIMARY; obj->u.primary = primary_obj; } /* drive_my_alloc - the exact production sequence a job's allocation * attempt runs: sched_ctx_init() -> sched_ctx_set_primary() (the real * call that stages ctx->alloc_req.shared via plugin_prepare_allocation()'s * ctx->worker==NULL branch, exactly as sched_make_primary_allocation() * does today) -> my_alloc(). Returns my_alloc()'s return code; ctx is left * initialized (caller must sched_ctx_fini()). Identical to * test_seqjob_rewrap_equivalence.c's own helper. */ static int drive_my_alloc(xce_scheduler_t* sched, xce_sched_obj_t* obj, xce_primary_resreq_t* primary, xce_sched_ctx_t* ctx) { xce_sched_plugin_t plugin; /* scheduler-sim-live-allocation spec, task 2.1: ctx->sched is now * honestly typed xce_sched_core_t* -- pass &(sched->core), not sched. */ TEST_ASSERT_EQUAL_INT(0, sched_ctx_init(ctx, &(sched->core), obj)); sched_ctx_set_primary(ctx, primary); memset(&plugin, 0, sizeof(plugin)); plugin.sched = sched; /* scheduler-sim-live-allocation spec, task 4.3: my_alloc() now reads * plugin->core (honestly typed, task 1.2) for every core-safe call, * alongside the still-needed plugin->sched (sched_make_allocs(), * scheduler.c, unchanged). */ plugin.core = &(sched->core); return my_alloc(&plugin, ctx); } /* assert_uncommitted_host_debit - reads ctx->uncomitted.hosts[w->idx] for * resource residx and asserts it equals `expected`. Identical to * test_seqjob_rewrap_equivalence.c's own helper. */ static void assert_uncommitted_host_debit(xce_sched_ctx_t* ctx, xce_sched_worker_t* w, int residx, float expected) { xvec_t* uncomit = NULL; int rc; rc = sched_ctx_get_uncomit_host(&uncomit, ctx, w->idx); TEST_ASSERT_EQUAL_INT(1, rc); TEST_ASSERT_NOT_NULL(uncomit); TEST_ASSERT_EQUAL_FLOAT(expected, ((float*)uncomit->data)[residx]); } static void bind_plan_iter(xce_alloc_plan_iter_t* iter) { memset(iter, 0, sizeof(*iter)); iter->init = parjob_plan_iter_init; iter->next = parjob_plan_iter_next; iter->fini = parjob_plan_iter_fini; } static void assert_plan_eq(xce_alloc_plan_t* plan, const int* expect, int nexpect) { int i; TEST_ASSERT_NOT_NULL(plan); TEST_ASSERT_EQUAL_INT(nexpect, plan->nbuckets); for (i = 0; i < nexpect; i++) { TEST_ASSERT_EQUAL_INT(expect[i], plan->bucket_slots[i]); } } /* ---------------------------------------------------------------------- */ /* Tests */ /* ---------------------------------------------------------------------- */ /* * Req 2 AC 2/AC 3, Req 4 AC 1: for maxTasks = 7, successive next() calls * return exactly one plan per call, in ascending host-count order * (k = 1..7), each plan the canonical balanced ceil/floor partition with * buckets in descending order. k=3 is the uneven-split worked example * from requirements.md: [3,2,2], not [2,2,3]/[2,3,2]/[5,1,1]. */ static void test_plans_ordered_ascending_host_count_with_balanced_partitions(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_storage; xce_alloc_plan_iter_t* iter = &iter_storage; xce_alloc_plan_t* plan; /* Expected balanced partition for maxTasks=7, per host-count k=1..7, * descending bucket order within each -- worked by hand against * requirements.md's "(N mod k) buckets get ceil(N/k), the rest get * floor(N/k)" rule. */ static const int expect_k1[] = { 7 }; static const int expect_k2[] = { 4, 3 }; static const int expect_k3[] = { 3, 2, 2 }; /* the uneven-split example */ static const int expect_k4[] = { 2, 2, 2, 1 }; static const int expect_k5[] = { 2, 2, 1, 1, 1 }; static const int expect_k6[] = { 2, 1, 1, 1, 1, 1 }; static const int expect_k7[] = { 1, 1, 1, 1, 1, 1, 1 }; memset(&resreq, 0, sizeof(resreq)); resreq.minTasks = 1; resreq.maxTasks = 7; bind_plan_iter(iter); TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL)); plan = iter->next(iter); assert_plan_eq(plan, expect_k1, 1); plan = iter->next(iter); assert_plan_eq(plan, expect_k2, 2); plan = iter->next(iter); assert_plan_eq(plan, expect_k3, 3); plan = iter->next(iter); assert_plan_eq(plan, expect_k4, 4); plan = iter->next(iter); assert_plan_eq(plan, expect_k5, 5); plan = iter->next(iter); assert_plan_eq(plan, expect_k6, 6); plan = iter->next(iter); assert_plan_eq(plan, expect_k7, 7); /* Req 2 AC 4: exhausted once k would exceed maxTasks (here k=8 > 7). */ TEST_ASSERT_NULL(iter->next(iter)); /* Stable across repeated post-exhaustion calls. */ TEST_ASSERT_NULL(iter->next(iter)); TEST_ASSERT_NULL(iter->next(iter)); iter->fini(iter); TEST_ASSERT_NULL(iter->private); } /* * A clean divisor case (maxTasks = 6): every partition splits evenly, so * every bucket in every plan carries the same slot count -- descending * order is trivially satisfied (all equal), and this exercises rem == 0 * for every k that evenly divides 6 (k=1,2,3,6) as well as k=4,5 where * rem != 0. */ static void test_plans_for_evenly_divisible_task_count(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_storage; xce_alloc_plan_iter_t* iter = &iter_storage; xce_alloc_plan_t* plan; static const int expect_k1[] = { 6 }; static const int expect_k2[] = { 3, 3 }; static const int expect_k3[] = { 2, 2, 2 }; static const int expect_k4[] = { 2, 2, 1, 1 }; static const int expect_k5[] = { 2, 1, 1, 1, 1 }; static const int expect_k6[] = { 1, 1, 1, 1, 1, 1 }; memset(&resreq, 0, sizeof(resreq)); resreq.minTasks = 1; resreq.maxTasks = 6; bind_plan_iter(iter); TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL)); plan = iter->next(iter); assert_plan_eq(plan, expect_k1, 1); plan = iter->next(iter); assert_plan_eq(plan, expect_k2, 2); plan = iter->next(iter); assert_plan_eq(plan, expect_k3, 3); plan = iter->next(iter); assert_plan_eq(plan, expect_k4, 4); plan = iter->next(iter); assert_plan_eq(plan, expect_k5, 5); plan = iter->next(iter); assert_plan_eq(plan, expect_k6, 6); TEST_ASSERT_NULL(iter->next(iter)); TEST_ASSERT_NULL(iter->next(iter)); iter->fini(iter); } /* * maxTasks = 1: exactly one plan (a single host, single slot), then * stable exhaustion. Degenerate but required boundary case -- parjob is * only ever reached for maxTasks > 1 in production (sched_make_primary_ * alloc_()'s dispatch), but the iterator itself makes no such assumption. */ static void test_single_task_yields_single_plan(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_storage; xce_alloc_plan_iter_t* iter = &iter_storage; xce_alloc_plan_t* plan; static const int expect_k1[] = { 1 }; memset(&resreq, 0, sizeof(resreq)); resreq.minTasks = 1; resreq.maxTasks = 1; bind_plan_iter(iter); TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL)); plan = iter->next(iter); assert_plan_eq(plan, expect_k1, 1); TEST_ASSERT_NULL(iter->next(iter)); iter->fini(iter); } /* * Req 4 AC 1 ("never inspects live worker state" / "pure function of the * task count alone"): the SAME maxTasks value must yield IDENTICAL plan * sequences regardless of what (if anything) `core` points to -- * including a NULL core, and a garbage-but-non-NULL one whose fields are * never touched. Since parjob_plan_iter_init()/_next() never dereference * `core` at all, a non-NULL "poisoned" core pointer that would crash on * any real field access is a stronger proof of purity than merely * passing NULL. */ static void test_plan_generation_ignores_core_state(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_a_storage; xce_alloc_plan_iter_t* iter_a = &iter_a_storage; xce_alloc_plan_iter_t iter_b_storage; xce_alloc_plan_iter_t* iter_b = &iter_b_storage; xce_sched_core_t* poisoned_core = (xce_sched_core_t*)0x1; int k; memset(&resreq, 0, sizeof(resreq)); resreq.minTasks = 1; resreq.maxTasks = 7; bind_plan_iter(iter_a); TEST_ASSERT_EQUAL_INT(0, iter_a->init(iter_a, &resreq, NULL)); bind_plan_iter(iter_b); /* A deliberately invalid, non-dereferenceable pointer -- init()/ * next() must never read through it, or this test would crash * instead of merely failing an assertion. */ TEST_ASSERT_EQUAL_INT(0, iter_b->init(iter_b, &resreq, poisoned_core)); for (k = 1; k <= 7; k++) { xce_alloc_plan_t* plan_a = iter_a->next(iter_a); xce_alloc_plan_t* plan_b = iter_b->next(iter_b); int i; TEST_ASSERT_NOT_NULL(plan_a); TEST_ASSERT_NOT_NULL(plan_b); TEST_ASSERT_EQUAL_INT(plan_a->nbuckets, plan_b->nbuckets); for (i = 0; i < plan_a->nbuckets; i++) { TEST_ASSERT_EQUAL_INT(plan_a->bucket_slots[i], plan_b->bucket_slots[i]); } } TEST_ASSERT_NULL(iter_a->next(iter_a)); TEST_ASSERT_NULL(iter_b->next(iter_b)); iter_a->fini(iter_a); iter_b->fini(iter_b); } /* * init() rejects invalid input rather than crashing: a NULL iter, a NULL * resreq, and a non-positive maxTasks. */ static void test_init_rejects_invalid_input(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_storage; xce_alloc_plan_iter_t* iter = &iter_storage; memset(&resreq, 0, sizeof(resreq)); resreq.maxTasks = 3; bind_plan_iter(iter); TEST_ASSERT_TRUE(iter->init(NULL, &resreq, NULL) != 0); TEST_ASSERT_TRUE(iter->init(iter, NULL, NULL) != 0); resreq.maxTasks = 0; TEST_ASSERT_TRUE(iter->init(iter, &resreq, NULL) != 0); } /* * Req 2 AC 6: next() calls pthread_testcancel() (or an equivalent real * cancellation point) at least once per invocation. Proved behaviorally, * same technique as test_host_iter.c's test_next_has_a_real_cancellation_ * point(): a worker thread parked in a tight loop calling next() * repeatedly (spinning past exhaustion, which still executes the * cancellation point on every call) is reliably torn down by * pthread_cancel() from the main thread. If parjob_plan_iter_next() had * no real cancellation point, this tight loop would never yield to the * pending cancellation and pthread_join() below would hang (timing out * the whole test binary) rather than return. */ typedef struct { xce_alloc_plan_iter_t* plan_iter; } cancel_thread_arg_t; static void* cancel_spin_thread(void* argp) { cancel_thread_arg_t* arg = (cancel_thread_arg_t*)argp; for (;;) { (void)arg->plan_iter->next(arg->plan_iter); } return NULL; /* unreachable */ } static void test_next_has_a_real_cancellation_point(void) { xce_primary_resreq_t resreq; xce_alloc_plan_iter_t iter_storage; xce_alloc_plan_iter_t* iter = &iter_storage; cancel_thread_arg_t arg; pthread_t tid; int rc; memset(&resreq, 0, sizeof(resreq)); resreq.minTasks = 1; resreq.maxTasks = 4; bind_plan_iter(iter); TEST_ASSERT_EQUAL_INT(0, iter->init(iter, &resreq, NULL)); arg.plan_iter = iter; rc = pthread_create(&tid, NULL, cancel_spin_thread, &arg); TEST_ASSERT_EQUAL_INT(0, rc); /* Let the spin thread actually get running before cancelling it. */ usleep(20000); rc = pthread_cancel(tid); TEST_ASSERT_EQUAL_INT(0, rc); rc = pthread_join(tid, NULL); TEST_ASSERT_EQUAL_INT(0, rc); /* Safe post-cancellation cleanup: pthread_testcancel() only ever * fires between complete logical steps (function entry), never * mid-mutation, so the iterator's private state is always * well-formed here regardless of exactly where cancellation * landed. */ iter->fini(iter); } /* ---------------------------------------------------------------------- */ /* Task 4.2 tests: the real per-bucket allocation loop */ /* ---------------------------------------------------------------------- */ /* * Req 4 AC 2/AC 3/AC 5/AC 6: a real multi-bucket plan, driven end to end * through the real my_alloc() (not parjob_try_plan_() directly). Two * hosts are engineered so the outcome is deterministic REGARDLESS of * sched->core.workers' own hash-based iteration order: host-A has exactly * 2 free slots (only it can satisfy a "need 2" bucket -- host-B's 1 free * slot never qualifies) and host-B has exactly 1 free slot. maxTasks=3 * forces k=1 ([3]) to fail first (no single host has >=3), so k=2's * [2,1] plan is the one that actually succeeds -- proving the bucket loop * itself works, not just the trivial single-bucket/single-plan case. * Also proves Req 4 AC 5/AC 6: parjob_prepare_allocation() must have * staged a "slots" want of exactly 2 (not a fixed 1, and not maxTasks=3) * for host-A's bucket, and exactly 1 for host-B's -- an unfixed stub or a * fixed-value bug would either allocate nothing or debit the wrong * amount, and assert_uncommitted_host_debit() below would catch either. */ static void test_multi_bucket_plan_succeeds_through_real_my_alloc(void) { xce_scheduler_t sched; xce_resource_t* slots_res; xce_sched_worker_t* host_a; xce_sched_worker_t* host_b; xce_sched_obj_t obj; xce_primary_schedobj_t primary_obj; xce_primary_resreq_t primary; xce_sched_ctx_t ctx; int rc; int i; int seen_a = 0; int seen_b = 0; 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); host_a = add_worker_with_slots(&(sched.core), "host-a", slots_res->idx, 2.0f); host_b = add_worker_with_slots(&(sched.core), "host-b", slots_res->idx, 1.0f); memset(&primary, 0, sizeof(primary)); primary.minTasks = 1; primary.maxTasks = 3; primary.select = NULL; init_primary_obj(&obj, &primary_obj, &primary); rc = drive_my_alloc(&sched, &obj, &primary, &ctx); TEST_ASSERT_EQUAL_INT(1, rc); TEST_ASSERT_NOT_NULL(ctx.alloc); TEST_ASSERT_EQUAL_INT(2, 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 */ if (strcmp(ha->host, "host-a") == 0) { seen_a = 1; } else if (strcmp(ha->host, "host-b") == 0) { seen_b = 1; } } TEST_ASSERT_TRUE(seen_a); TEST_ASSERT_TRUE(seen_b); /* host-a's own bucket needed 2 slots, host-b's needed 1 -- the exact * per-bucket want Req 4 AC 5/AC 6 requires, not a fixed 1 (seqjob's * old hardcode) or maxTasks=3 (a different, equally wrong fixed * value). */ assert_uncommitted_host_debit(&ctx, host_a, slots_res->idx, 2.0f); assert_uncommitted_host_debit(&ctx, host_b, slots_res->idx, 1.0f); sched_ctx_fini(&ctx); } /* * bug-1882: a two-host plan whose allocation records cannot be built -- * first for host 0, then for host 1 after host 0's record already exists -- * must report -ENOMEM with ctx->alloc left NULL (the caller moves ctx->alloc * onto the job whatever the rc). ASAN catches a double free or use after * free on the cleanup path. */ static void test_my_alloc_reports_enomem_when_allocs_cannot_be_built(void) { int fail_at; for (fail_at = 0; fail_at < 2; fail_at++) { xce_scheduler_t sched; xce_resource_t* slots_res; xce_sched_obj_t obj; xce_primary_schedobj_t primary_obj; xce_primary_resreq_t primary; xce_sched_ctx_t ctx; int rc; 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); add_worker_with_slots(&(sched.core), "host-a", slots_res->idx, 2.0f); add_worker_with_slots(&(sched.core), "host-b", slots_res->idx, 1.0f); memset(&primary, 0, sizeof(primary)); primary.minTasks = 1; primary.maxTasks = 3; primary.select = NULL; init_primary_obj(&obj, &primary_obj, &primary); g_make_allocs_calls = 0; g_make_allocs_fail_at = fail_at; rc = drive_my_alloc(&sched, &obj, &primary, &ctx); g_make_allocs_fail_at = -1; TEST_ASSERT_EQUAL_INT(-ENOMEM, rc); TEST_ASSERT_NULL(ctx.alloc); sched_ctx_fini(&ctx); } } /* * Req 4 AC 2: the exact descending-vs-ascending worked example from * requirements.md's Composition Algorithm. Two hosts, capacities 1 and 2; * WHICH physical host holds which capacity is assigned by a real, * independent iter_make()/iter_next() scan (the identical primitive * host_iter.c itself uses over the same sched.core.workers) so the test * does not depend on assuming insertion order matches hash-table * iteration order -- the same technique * test_seqjob_rewrap_equivalence.c's own iteration-order fixture uses. * The 2-free-slot capacity goes to whichever host is visited FIRST, * matching requirements.md's own worked example precisely (a fresh scan * for a small bucket greedily claims whatever it encounters first that * already satisfies it). * * Driven directly through parjob_try_plan_() (not the real plan * iterator, which only ever generates descending order) with a * hand-built xce_alloc_plan_t for each order, against the SAME * unmodified host pool (the descending attempt's claims are rolled back * before the ascending attempt runs) -- this is the seam task 4.2's own * bucket-loop factoring (out of my_alloc()) exists to make testable * without reimplementing the loop in the test. */ static void test_descending_bucket_order_succeeds_where_ascending_fails(void) { xce_scheduler_t sched; xce_resource_t* slots_res; xce_sched_worker_t* host_first; xce_sched_worker_t* host_second; iterator_t it; xce_sched_obj_t obj; xce_primary_schedobj_t primary_obj; xce_primary_resreq_t primary; xce_sched_ctx_t ctx; xce_alloc_plan_t plan; int bucket_slots_desc[2]; int bucket_slots_asc[2]; parjob_claim_t claimed[2]; int nclaimed; int rc; int idx[1]; float val[1]; 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); /* Added with a placeholder capacity -- real capacities are assigned * below, once the shared iteration order is known. */ add_worker_with_slots(&(sched.core), "host-1", slots_res->idx, 0.0f); add_worker_with_slots(&(sched.core), "host-2", slots_res->idx, 0.0f); rc = iter_make(&it, sched.core.workers); TEST_ASSERT_TRUE(rc >= 0); rc = iter_next(&it, (void**)&host_first); TEST_ASSERT_TRUE(rc > 0); rc = iter_next(&it, (void**)&host_second); TEST_ASSERT_TRUE(rc > 0); iter_destroy(&it); idx[0] = slots_res->idx; val[0] = 2.0f; put_resvals(&(host_first->resvals), 1, idx, val); val[0] = 1.0f; put_resvals(&(host_second->resvals), 1, idx, val); memset(&primary, 0, sizeof(primary)); primary.minTasks = 1; primary.maxTasks = 3; primary.select = NULL; init_primary_obj(&obj, &primary_obj, &primary); /* scheduler-sim-live-allocation spec, task 2.1: ctx->sched is now * honestly typed xce_sched_core_t* -- pass &(sched.core), not &sched. */ TEST_ASSERT_EQUAL_INT(0, sched_ctx_init(&ctx, &(sched.core), &obj)); sched_ctx_set_primary(&ctx, &primary); /* my_alloc() always lazily creates ctx->reason before ever reaching * parjob_try_plan_() (see my_alloc()'s own top-of-function guard); * this test calls parjob_try_plan_() directly, bypassing that step, * so it must set one up itself -- sched_try_alloc_on_host_() * (resource.c) unconditionally records a pend reason into * ctx->reason->reasons on a resource-unmet candidate, which the * ascending attempt below deliberately exercises (bucket1 failing * "need 2" against host_second's 1 free slot). */ ctx.reason = make_pendreason(0); TEST_ASSERT_NOT_NULL(ctx.reason); /* Descending [2,1] -- the plan iterator's own v1 default order -- * succeeds: bucket0 ("need 2") can only be satisfied by host_first * (2 free); bucket1 ("need 1") then claims host_second (1 free), * the only host left. */ bucket_slots_desc[0] = 2; bucket_slots_desc[1] = 1; memset(&plan, 0, sizeof(plan)); plan.nbuckets = 2; plan.bucket_slots = bucket_slots_desc; /* scheduler-sim-live-allocation spec, task 4.3: parjob_try_plan_() is * now honestly typed xce_sched_core_t* (mirrors task 2.1's identical * ctx->sched ripple immediately above) -- pass &(sched.core), not * &sched. */ rc = parjob_try_plan_(&(sched.core), &ctx, &plan, claimed, &nclaimed); TEST_ASSERT_EQUAL_INT(1, rc); TEST_ASSERT_EQUAL_INT(2, nclaimed); TEST_ASSERT_TRUE(claimed[0].w == host_first); TEST_ASSERT_TRUE(claimed[1].w == host_second); /* Roll back before trying the ascending variant against the exact * same, otherwise-untouched host pool. */ parjob_rollback_claims_(&ctx, claimed, nclaimed); assert_uncommitted_host_debit(&ctx, host_first, slots_res->idx, 0.0f); assert_uncommitted_host_debit(&ctx, host_second, slots_res->idx, 0.0f); /* Ascending [1,2] -- the SAME host pool -- fails: bucket0 ("need 1") * greedily claims host_first (2 free, visited first, trivially * satisfies "need 1" even though it is overqualified), leaving only * host_second (1 free) for bucket1 ("need 2"), which cannot satisfy * it. */ bucket_slots_asc[0] = 1; bucket_slots_asc[1] = 2; memset(&plan, 0, sizeof(plan)); plan.nbuckets = 2; plan.bucket_slots = bucket_slots_asc; /* scheduler-sim-live-allocation spec, task 4.3: parjob_try_plan_() is * now honestly typed xce_sched_core_t* (mirrors task 2.1's identical * ctx->sched ripple immediately above) -- pass &(sched.core), not * &sched. */ rc = parjob_try_plan_(&(sched.core), &ctx, &plan, claimed, &nclaimed); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(0, nclaimed); /* No phantom debit left behind by the failed ascending attempt. */ assert_uncommitted_host_debit(&ctx, host_first, slots_res->idx, 0.0f); assert_uncommitted_host_debit(&ctx, host_second, slots_res->idx, 0.0f); sched_ctx_fini(&ctx); } /* * Req 4 AC 2 ("Rollback scope"): a mid-plan bucket failure must reverse * every earlier bucket's provisional debit in that SAME plan attempt * before the next candidate plan is tried. Proved the strong way -- not * just "the debit reads back as zero" but "the SAME host is successfully * reclaimed by a LATER plan attempt for a full, real allocation," which * could only happen if the earlier phantom debit was genuinely reversed * (sched_check_host_satisfy() would otherwise reject the later attempt's * own fresh want against a host that still looked partially consumed). * * Fixture: h1 (2 free), h2/h3/h4 (1 free each), maxTasks=4. * k=1 ([4]): fails (no single host has >=4). * k=2 ([2,2]): bucket0 ("need 2") can only be satisfied by h1 (the only * host with >=2 free) -- claims h1. bucket1 ("need 2"), excluding h1, * has only h2/h3/h4 left, each with just 1 free -- ALL fail. The * whole k=2 plan fails, and h1's provisional debit from bucket0 must * be rolled back here. * k=3 ([2,1,1]): a fresh, empty claimed[] (independent of k=2's * rolled-back attempt) -- bucket0 ("need 2") again only h1 qualifies; * if k=2's debit on h1 had NOT been reversed, h1 would appear to * already carry a 2.0 phantom debit and sched_check_host_satisfy() * would reject this fresh "need 2" want against it (2 total < 2 * phantom + 2 new) -- the whole allocation would fail outright * instead of succeeding. bucket1/bucket2 ("need 1" each) then claim * two of the three remaining 1-free-slot hosts. SUCCEEDS with exactly * 3 claimed hosts. */ static void test_mid_plan_bucket_failure_rolls_back_before_later_plan_succeeds(void) { xce_scheduler_t sched; xce_resource_t* slots_res; xce_sched_worker_t* h1; xce_sched_worker_t* h2; xce_sched_worker_t* h3; xce_sched_worker_t* h4; xce_sched_obj_t obj; xce_primary_schedobj_t primary_obj; xce_primary_resreq_t primary; xce_sched_ctx_t ctx; int rc; int i; int seen_h1 = 0; int nother = 0; xvec_t* u2 = NULL; xvec_t* u3 = NULL; xvec_t* u4 = NULL; float other_sum; 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); h1 = add_worker_with_slots(&(sched.core), "h1", slots_res->idx, 2.0f); h2 = add_worker_with_slots(&(sched.core), "h2", slots_res->idx, 1.0f); h3 = add_worker_with_slots(&(sched.core), "h3", slots_res->idx, 1.0f); h4 = add_worker_with_slots(&(sched.core), "h4", slots_res->idx, 1.0f); memset(&primary, 0, sizeof(primary)); primary.minTasks = 1; primary.maxTasks = 4; primary.select = NULL; init_primary_obj(&obj, &primary_obj, &primary); rc = drive_my_alloc(&sched, &obj, &primary, &ctx); TEST_ASSERT_EQUAL_INT(1, rc); TEST_ASSERT_NOT_NULL(ctx.alloc); TEST_ASSERT_EQUAL_INT(3, 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); if (strcmp(ha->host, "h1") == 0) { seen_h1++; } else { nother++; } } TEST_ASSERT_EQUAL_INT(1, seen_h1); TEST_ASSERT_EQUAL_INT(2, nother); /* THE regression assertion: h1 carries exactly its own bucket's 2.0 * want in the final, committed state -- not 4.0 (which a missing * rollback would leave: 2.0 phantom from the failed k=2 attempt plus * 2.0 genuine from k=3's own successful claim). */ assert_uncommitted_host_debit(&ctx, h1, slots_res->idx, 2.0f); /* Exactly two of h2/h3/h4 were claimed at 1.0 each (the third stayed * at 0.0, host_iter's own internal candidate order decides which) -- * proves neither a double-debit nor a leaked phantom debit from the * rolled-back k=2 attempt. */ TEST_ASSERT_EQUAL_INT(1, sched_ctx_get_uncomit_host(&u2, &ctx, h2->idx)); TEST_ASSERT_EQUAL_INT(1, sched_ctx_get_uncomit_host(&u3, &ctx, h3->idx)); TEST_ASSERT_EQUAL_INT(1, sched_ctx_get_uncomit_host(&u4, &ctx, h4->idx)); other_sum = ((float*)u2->data)[slots_res->idx] + ((float*)u3->data)[slots_res->idx] + ((float*)u4->data)[slots_res->idx]; TEST_ASSERT_EQUAL_FLOAT(2.0f, other_sum); sched_ctx_fini(&ctx); } /* * Req 4 AC 4: when every candidate plan is exhausted without a full * success, my_alloc() returns the same not-satisfied signal (0) the old * stub always returned, unconditionally -- pend-reason handling * downstream is unaffected by this task's change. */ static void test_returns_zero_when_every_plan_is_exhausted(void) { xce_scheduler_t sched; xce_resource_t* slots_res; xce_sched_obj_t obj; xce_primary_schedobj_t primary_obj; xce_primary_resreq_t primary; xce_sched_ctx_t ctx; int rc; 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); /* A single host with only 1 free slot can never satisfy ANY plan for * a 3-task job (k=1 needs 3 on one host; k=2/k=3 each need a SECOND * distinct host that does not exist). */ add_worker_with_slots(&(sched.core), "lonely-host", slots_res->idx, 1.0f); memset(&primary, 0, sizeof(primary)); primary.minTasks = 1; primary.maxTasks = 3; primary.select = NULL; init_primary_obj(&obj, &primary_obj, &primary); rc = drive_my_alloc(&sched, &obj, &primary, &ctx); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_NULL(ctx.alloc); 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) { g_make_allocs_fail_at = -1; g_make_allocs_calls = 0; } void tearDown(void) { return; } int main(void) { UNITY_BEGIN(); RUN_TEST(test_plans_ordered_ascending_host_count_with_balanced_partitions); RUN_TEST(test_plans_for_evenly_divisible_task_count); RUN_TEST(test_single_task_yields_single_plan); RUN_TEST(test_plan_generation_ignores_core_state); RUN_TEST(test_init_rejects_invalid_input); RUN_TEST(test_next_has_a_real_cancellation_point); RUN_TEST(test_multi_bucket_plan_succeeds_through_real_my_alloc); RUN_TEST(test_my_alloc_reports_enomem_when_allocs_cannot_be_built); RUN_TEST(test_descending_bucket_order_succeeds_where_ascending_fails); 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(); }