myxce/src/scheduler/testsuite/test_sim_alloc_limit_fanout.c
Rongsong Shen f6a2862110
feat(scheduler-sim-live-allocation): close host-scoped limit fan-out seam gap (task 4.3)
Fully closes bug-1829's own tracked follow-up from task 4.1: retypes
seqjob_allocator.c/parjob_allocator.c's init_fn AND runtime hooks
(prepare_allocation_hook, job_alloc_fn) to read the plugin's own
honest plugin->core field (task 1.2) instead of deriving an
xce_scheduler_t* from plugin->sched -- mirroring task 2.3's limit.c
migration exactly. The gap was broader than initially scoped: not
just init_fn, but every runtime hook independently derived sched from
plugin->sched on each allocation attempt.

Adds seqjob_allocator_new_plugin()/parjob_allocator_new_plugin()
factories (plugin.h, mirroring limit_new_plugin()), letting
sim_alloc.c's bootstrap functions register both plugins DIRECTLY
against &(sim->core) via the existing plugin_register_core() -- the
same call sim_bootstrap_picker()/sim_bootstrap_limit_plugin() already
use. Task 4.1's throwaway xce_scheduler_t wrapper is gone from the
plugin-registry/fan-out path entirely; plugin_prepare_allocation()'s
fan-out during a live seqjob/parjob dispatch now genuinely reaches
sim->core.plugins, the same registry the limit plugin is registered
in.

One narrow, necessary exception remains, independently verified
confined: my_alloc() still needs a small xce_scheduler_t wrapper
(only .core.resmgr aliased to sim->core) to satisfy sched_make_allocs()
(scheduler.c, out of this task's boundary, unmodified) -- set on
plugin->sched only AFTER plugin_register_core() has run, never read by
the plugin registry or any prepare_allocation_hook fan-out.

TDD: new test_sim_alloc_limit_fanout.c drives a real live dispatch
(sched_job_picker()/sched_allocator() thread pair) with a host-scoped
limit registered directly against sim->core, proving a real usage
instance is created -- isolated from both reconciliation and
global-limit matching. RED: dispatch succeeded but the usage-instance
iterator was empty. GREEN: 82/82, zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
2026-09-19 07:38:34 +00:00

389 lines
18 KiB
C

/*
* test_sim_alloc_limit_fanout.c - scheduler-sim-live-allocation spec, task
* 4.3 (Req 2.2, boundary: Unified Picker/Allocator Threads, Limit Plugin
* Core-Safe Matching).
*
* ── The gap this closes (design.md's "Known Seam Gap: Host-Scoped Limit
* Fan-Out During Live Seqjob/Parjob Dispatch") ─────────────────────────
* Task 4.1's sim_bootstrap_seqjob_allocator()/sim_bootstrap_parjob_allocator()
* (sim_alloc.c) registered the seqjob/parjob allocator plugin against a
* throwaway xce_scheduler_t wrapper's OWN separate core.plugins registry --
* required only because register_seqjob_allocator()/register_parjob_
* allocator()'s own init_fn (seqjob_allocator_init()/parjob_allocator_init(),
* plugins/seqjob_allocator.c/parjob_allocator.c) still read plugin->sched,
* needing a real xce_scheduler_t*. seqjob_allocator.c's/parjob_allocator.c's
* own my_alloc() then resolves its plugin-hook fan-out (plugin_prepare_
* allocation()) via THAT SAME plugin instance's own plugin->core field, not
* the core parameter threaded down from sched_make_allocation() -- so with
* plugin->core pointing at the wrapper's own core, the fan-out iterates the
* wrapper's registry (holding only the allocator plugin itself), never
* reaching a plugin registered on the REAL simulation core (sim->core) --
* the limit plugin (sim_bootstrap_limit_plugin()) included. A host-scoped
* limit's usage instance could therefore never be created via a live
* seqjob/parjob dispatch attempt.
*
* This test proves the fix: a host-scoped limit registered directly against
* simulation mode's real core (sim_bootstrap_limit_plugin(), unchanged) is
* matched -- and its usage instance created -- during a GENUINE seqjob
* dispatch attempt, driven through the real, unmodified sched_job_picker()/
* sched_allocator() thread pair (sched_election.c) spawned against nothing
* but &(sim.core), the exact shape sched_sim_driver_run() itself uses.
* Independent of recorded-decision reconciliation: this fixture never
* builds a .replay stream or calls sim_reconcile_unaccounted_jobs() at all.
* Independent of global-limit matching: the one limit configured here is
* host-scoped (a percent-based limit on a real, non-GLOBAL/non-SHARED
* resource), never a global limit.
*
* ── Link style: real scheduler_srcs, not a white-box #include ────────────
* Same technique test_sched_election_sim_fixture.c/test_sim_live_
* allocation.c already establish (see those files' own header comments):
* links the REAL, unmodified `scheduler_srcs` object list (minus
* src/main.c) so my_alloc()'s real fan-out through the real plugin.c/
* resource.c/plugins/limit.c chain is genuinely exercised, not stubbed.
*
* ── Fixture shape ──────────────────────────────────────────────────────
* A bare xce_sched_sim_driver_t, built and bootstrapped by hand (mirroring
* test_sched_election_sim_fixture.c's own "bare core fixture" technique)
* rather than driven through a real .replay file: xce_sched_core_init(),
* then the SAME four bootstrap calls sched_sim_driver_run() (sim_loop.c)
* itself makes, in the same order, all against this one real sim.core --
* sim_bootstrap_picker(), sim_bootstrap_limit_plugin() (seeded with one
* host-scoped limit config), sim_bootstrap_seqjob_allocator(),
* sim_bootstrap_parjob_allocator(). A real worker (added directly via
* sched_add_worker(), not a wire WORKERS_PUSH ingest -- this fixture needs
* no replay/capnp machinery) gives the job's auto-injected "slots" want and
* this test's own limit resource genuine capacity, so the fixture proves
* the STRONGEST possible outcome: a real DISPATCH, not merely a PEND that
* happened to touch the fan-out.
*
* `job_wrapper` (a genuine, real, but otherwise-unused xce_scheduler_t)
* exists SOLELY to satisfy make_sched_job()'s own out-of-boundary
* precondition (schedobj.c still takes `void* arg` cast to xce_scheduler_t*
* -- a pre-existing gap task 3.1's own fixture already routes around the
* same way). Its core.resmgr/workers are ALIASED (not copied) to sim.core's
* own -- the same "var is a plain assignable reference" technique task
* 4.1's own sim_alloc.c fix established -- so parse_resreq() resolves
* resource names against the exact same resmgr this test's real dispatch
* attempt uses. job_wrapper is never passed to anything else.
*
* Same "fixtures here are never torn down, exactly one RUN_TEST(), process
* exits immediately after" convention every other real-scheduler_srcs
* bare-core-fixture test in this directory follows (test_sched_election_
* sim_fixture.c's own file header comment) -- the two spawned, never-
* joined picker/allocator threads read a stack-local `sim` for the rest of
* the process's short remaining life, which is safe for the same reason
* documented there.
*/
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "controller.capnp.h"
#include "decision.h"
#include "plugin.h"
#include "preason.h"
#include "resobj.h"
#include "sched/types.h"
#include "scheduler.h"
#include "sim.h"
#include "xcelimit.h"
#include "xstring.h"
/* Consumed the same "extern declared where consumed" way sim_loop.c itself
* declares these four (sim_loop.c's own file header comment) -- none of
* them has a shared header declaration. */
extern void sim_bootstrap_picker(xce_sched_sim_driver_t* sim);
extern void sim_bootstrap_limit_plugin(xce_sched_sim_driver_t* sim);
extern void sim_bootstrap_seqjob_allocator(xce_sched_sim_driver_t* sim);
extern void sim_bootstrap_parjob_allocator(xce_sched_sim_driver_t* sim);
#define TEST_JOBID ((uint64_t)430301ULL)
#define QUEUE_NAME "normal"
#define TEST_RESREQ ((char*)"select[type==any] rusage[mem=1]")
#define WORKER_NAME "sim_alloc_limit_worker"
#define WORKER_SLOTS 4.0
#define WORKER_HOSTRES 1000.0
#define LIMIT_RESNAME "sim_alloc_host_res"
#define LIMIT_NAME "sim_alloc_host_limit"
void setUp(void) { return; }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Fixture helpers */
/* ---------------------------------------------------------------------- */
static xce_resource_t* register_plain_resource(var resmgr, const char* name)
{
xce_resource_t res;
memset(&res, 0, sizeof(res));
res.name = strdup(name);
return makeResource(resmgr, &res);
}
/* add_test_worker_resval - same field-population step core_build_worker()/
* make_sched_worker() (core_events.c/schedobj.c) use to fill a worker's
* resvals, applied by hand since neither of those two helpers is reachable
* without a real wire xce_worker_t payload (out of this task's boundary to
* add one). */
static void add_test_worker_resval(xce_sched_worker_t* worker,
xce_sched_core_t* core,
const char* resname, double value)
{
xce_resource_t* r = NULL;
xce_val_t* val = NULL;
int rc;
rc = key_find(core->resmgr, (void*)resname, (void**)&r);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_NULL(r);
val = (xce_val_t*)calloc(1, sizeof(*val));
TEST_ASSERT_NOT_NULL(val);
val->which = Value_u_fvalue;
val->u.fvalue = value;
zarray_put(&(worker->resvals), r->idx, val);
}
static xce_sched_worker_t* make_test_worker(xce_sched_core_t* core,
const char* name)
{
xce_sched_worker_t* worker = (xce_sched_worker_t*)calloc(1,
sizeof(*worker));
TEST_ASSERT_NOT_NULL(worker);
worker->name = strdup(name);
worker->status = 0;
worker->idx = -1;
zarray_init(&(worker->resvals), 128, (zarray_item_killer_t)kill_sched_resval);
zarray_init(&(worker->resInUse), 128, (zarray_item_killer_t)kill_sched_resval);
add_test_worker_resval(worker, core, "slots", WORKER_SLOTS);
add_test_worker_resval(worker, core, LIMIT_RESNAME, WORKER_HOSTRES);
return worker;
}
/* ---------------------------------------------------------------------- */
/* The test */
/* ---------------------------------------------------------------------- */
static void
test_host_scoped_limit_usage_instance_created_via_live_sim_dispatch(void)
{
xce_sched_sim_driver_t sim;
xce_scheduler_t job_wrapper; /* setup-only, see file header */
xce_sched_plugin_t* picker = NULL;
xce_sched_plugin_t* limit_plugin = NULL;
xce_sched_queue_t* queue;
xce_sched_worker_t* worker;
xce_job_t j;
job_submit_data_t submit;
xce_sched_job_t* job;
xce_limit_val_t limit_val = { .resname = (char*)LIMIT_RESNAME,
.percent = 1,
.val = 50.0 };
xce_limit_config_t limit_cfg = { .name = (char*)LIMIT_NAME,
.val = &limit_val };
xce_limit_config_t* limits[1] = { &limit_cfg };
xce_conf4sched_t config;
pthread_t picker_tid;
pthread_t allocator_tid;
xce_decision_t* decision = NULL;
xce_decision_t* end_marker = (xce_decision_t*)0x1;
var instances_handle;
iterator_t it;
void* item = NULL;
int rc;
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
/* Real production resources: "slots" (the auto-injected per-task want
* every primary resreq gets) and LIMIT_RESNAME (this test's own
* host-scoped limit's underlying resource -- instantiate_limit()
* (xcelimit.c) requires it to genuinely exist in core->resmgr, same
* requirement test_repl_limit_table.c's own host-scoped fixture
* documents). Neither carries XCE_RESF_GLOBAL/XCE_RESF_SHARED, so
* is_host_based_limit() resolves LIMIT_NAME as host-based purely from
* its own percent!=0 shape. */
TEST_ASSERT_NOT_NULL(register_plain_resource(sim.core.resmgr, "slots"));
TEST_ASSERT_NOT_NULL(
register_plain_resource(sim.core.resmgr, LIMIT_RESNAME));
/* Real production bootstrap sequence -- the SAME four calls
* sched_sim_driver_run() (sim_loop.c) makes, in the SAME order, all
* against this one real sim.core. */
sim_bootstrap_picker(&sim);
rc = key_find(sim.core.plugins, "builtin_fcfs", (void**)&picker);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_NULL(picker);
memset(&config, 0, sizeof(config));
config.nlimits = 1;
config.limits = limits;
sim.core.config = &config;
sim_bootstrap_limit_plugin(&sim);
rc = key_find(sim.core.plugins, "xce_sched_limit", (void**)&limit_plugin);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_NULL(limit_plugin);
TEST_ASSERT_EQUAL_PTR_MESSAGE(
&(sim.core), limit_plugin->core,
"sanity: the limit plugin must be registered directly against "
"sim.core -- the SAME registry this test's seqjob/parjob allocator "
"plugin must ALSO reach for its own prepare_allocation_hook "
"fan-out to ever find it");
/* THE fix under test (task 4.3): sim_bootstrap_seqjob_allocator()/
* sim_bootstrap_parjob_allocator() must register their plugin
* instance's own `core` field against this SAME sim.core -- not a
* throwaway wrapper's own separate registry (task 4.1's own
* documented gap, design.md's "Known Seam Gap: Host-Scoped Limit
* Fan-Out During Live Seqjob/Parjob Dispatch"). */
sim_bootstrap_seqjob_allocator(&sim);
sim_bootstrap_parjob_allocator(&sim);
TEST_ASSERT_NOT_NULL_MESSAGE(
sim.core.seqjob_allocator,
"sim_bootstrap_seqjob_allocator() must populate core->seqjob_"
"allocator (task 4.1, unrelated to this task's own fix)");
/* Real queue registration -- same sequence test_sched_election_sim_
* fixture.c's own fixture establishes. */
queue = (xce_sched_queue_t*)calloc(1, sizeof(*queue));
TEST_ASSERT_NOT_NULL(queue);
queue->name = makeString(QUEUE_NAME);
rc = sched_add_queue(&(sim.core), queue);
TEST_ASSERT_TRUE(rc >= 0);
picker->picker_iface->on_queue_update(picker, queue);
/* A real worker with genuine "slots" AND LIMIT_RESNAME capacity, added
* directly via the real sched_add_worker() (core.c). */
worker = make_test_worker(&(sim.core), WORKER_NAME);
rc = sched_add_worker(&(sim.core), worker);
TEST_ASSERT_TRUE(rc >= 0);
/* Real job construction via make_sched_job() -- see file header for
* why job_wrapper (a real xce_scheduler_t, its core.resmgr/workers
* ALIASED to sim.core's own) is used only for this one call. */
memset(&job_wrapper, 0, sizeof(job_wrapper));
rc = xce_sched_core_init(&(job_wrapper.core));
TEST_ASSERT_EQUAL_INT(0, rc);
job_wrapper.core.resmgr = sim.core.resmgr;
job_wrapper.core.workers = sim.core.workers;
/* Also alias .queues (not just .resmgr/.workers): make_sched_job()'s
* own internal sched_get_queue(&(job_wrapper.core), ...) call is
* get-only (never auto-creates, core.c) -- without this alias it
* would look up "normal" in job_wrapper.core's own separate, empty
* queue registry and return NULL, leaving job->queue NULL, which
* silently fails builtin_fcfs_on_job_push()'s own (job->queue == NULL)
* guard (returns -EINVAL, job never becomes a picker candidate) --
* caught via gdb during this test's own RED-phase debugging. */
job_wrapper.core.queues = sim.core.queues;
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)QUEUE_NAME;
submit.user = (char*)"sim_alloc_limit_user";
submit.project_name = (char*)"default";
submit.resreq = TEST_RESREQ;
submit.mintasks = 1;
submit.maxtasks = 1;
memset(&j, 0, sizeof(j));
j.jobid = TEST_JOBID;
j.state = JOB_STAT_PEND;
j.submit = &submit;
job = make_sched_job(&job_wrapper, &j);
TEST_ASSERT_NOT_NULL(job);
TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, job->schedobj->tag);
/* make_sched_job()'s own parse_resreq() call does not thread
* submit.mintasks/maxtasks into the parsed primary's own minTasks/
* maxTasks (task 3.1's own documented gap) -- set directly so this
* fixture deterministically exercises the seqjob path. */
job->schedobj->u.primary->resreq->minTasks = 1;
job->schedobj->u.primary->resreq->maxTasks = 1;
rc = sched_add_job(&(sim.core), job);
TEST_ASSERT_TRUE(rc >= 0);
picker->picker_iface->on_job_push(picker, job);
/* ── The actual thing under test ─────────────────────────────────
* the REAL, unmodified sched_job_picker()/sched_allocator() thread
* pair (sched_election.c), spawned against nothing but &(sim.core) --
* exactly the shape sched_sim_driver_run() itself uses. */
rc = pthread_create(&picker_tid, NULL, sched_job_picker, &(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
rc = pthread_create(&allocator_tid, NULL, sched_allocator, &(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_core_go(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
cbuf_get(&(sim.core.decisions), &decision);
TEST_ASSERT_NOT_NULL_MESSAGE(
decision, "the real thread pair must carry this job all the way to "
"a committed decision");
cbuf_get(&(sim.core.decisions), &end_marker);
TEST_ASSERT_NULL_MESSAGE(end_marker,
"second decisions-cbuf entry must be the "
"end-of-session NULL marker");
rc = xce_core_end_session(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT_MESSAGE(
JOB_STAT_RUN, job->state,
"the worker's real slots/host-resource capacity must be genuinely "
"sufficient for a real DISPATCH -- the strongest possible proof "
"that the per-host prepare_allocation_hook fan-out this test cares "
"about actually ran, not merely that some decision was reached");
/* The Observable this task's brief requires: a host-scoped limit's
* usage instance is visible via the existing usage-instances accessor,
* created by this LIVE dispatch attempt alone -- reconciliation and
* global-limit matching were never invoked anywhere in this fixture. */
instances_handle = limit_get_usage_instances(&(sim.core));
TEST_ASSERT_NOT_NULL(instances_handle);
rc = iter_make(&it, instances_handle);
TEST_ASSERT_TRUE(rc >= 0);
rc = iter_next(&it, &item);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_EQUAL(RTTI_ITER_NO_DATA, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(
item,
"a host-scoped limit registered directly against sim.core must be "
"matched -- and its usage instance created -- during a genuine "
"live seqjob/parjob dispatch attempt; before task 4.3's fix the "
"seqjob/parjob allocator plugin's own plugin->core pointed at a "
"throwaway wrapper's separate registry, so this fan-out never "
"reached the limit plugin registered on sim.core");
iter_destroy(&it);
printf(
"test_host_scoped_limit_usage_instance_created_via_live_sim_"
"dispatch: PASS\n");
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(
test_host_scoped_limit_usage_instance_created_via_live_sim_dispatch);
return UNITY_END();
}