myxce/src/scheduler/testsuite/test_parjob_combinatorial.c
Rongsong Shen 86ee3491dd
feat(scheduler-allocator-runner): plan generators initialise from the scheduling core (task 1.1)
xce_alloc_plan_iter_t.init takes xce_sched_core_t* instead of
xce_scheduler_t*; both built-in generators, both allocators' call sites
and the contract-test stub follow. The parjob purity test now shows an
identical plan sequence for a NULL and a poisoned core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 07:44:27 +00:00

1243 lines
48 KiB
C

/*
* 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 <errno.h>
#include <pthread.h>
#include <rtti.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#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();
}