scheduler tests: UT cover for E2 Phase 3 increments (adoption shared allocs + per-queue instances)

Two gaps in the Phase 3 regression net:

1. test_core_events.c: test_job_update_run_adoption_carries_shared_allocs
   -- scheduler-side counterpart of bug-1976. A JOB_UPDATE marking a job
   RUN whose jalloc carries shared-pool records (the per-project limit
   charge + the external resource itself) must rebuild job->accounts
   with accounts_shared mirroring them, or the next sched_account_jobs
   pass never charges the limit vres and every global limit admits
   forever. Also pins the payload ownership move (updated.allocs == NULL).

2. test_limit_project_scope.c: test_queue_scope_per_queue_instance_names
   -- the per_queue shape of limits.yaml's extlic_qdist: queues:-scoped
   limit slices its usage instance per queue ("limit=<name>/q=<queue>")
   exactly as per_project slices by project ("/p="); cap lands at the
   instance's own vres idx; out-of-scope queues match nothing. Uses the
   same heap group-config lifetime contract as the project shape, so
   free_limit_config() gained queues handling (and NULL-guarded the
   projects group -- per_queue configs carry projects == NULL, which the
   unconditional dereference crashed on; deterministic under
   MALLOC_PERTURB_, every run).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-28 05:57:15 +00:00
parent 13a28a387a
commit 700f3d4e1a
2 changed files with 196 additions and 2 deletions

View file

@ -900,6 +900,99 @@ static void test_job_update_run_adopts_controller_allocation(void)
"ownership of the allocation moves to the scheduler's job");
}
/* bug-1976 companion (external-license-resources Phase 3): the controller's
* RUN adoption is the scheduler's ONLY view of a dispatch (the controller is
* ground truth), so the adopted wire alloc must carry the shared-pool
* records too -- the limit vres charge among them (allocs_shared; the
* controller-side drop was bug-1976 itself, fixed in dispatch_a_job_()).
* Without them make_job_account() builds accounts_shared == NULL and
* sched_account_jobs() never charges the vres: sharedInUse stays 0.0 and
* unsatisfy_'s non-EXTERNAL branch (total >= inuse + uncomit + wanted)
* admits past every global limit forever. This pins the scheduler side of
* the round trip: an adoption whose wire alloc HAS shared records must
* produce accounts whose accounts_shared mirror them. */
static void test_job_update_run_adoption_carries_shared_allocs(void)
{
xce_sched_core_t core;
xce_core_event_t event;
xce_job_t* job1;
xce_job_t updated;
xce_sched_job_t* job;
xce_alloc_t* a_slots;
xce_host_alloc_t* h0;
xce_host_alloc_t* hallocs[1];
xce_job_alloc_t* jalloc;
xce_alloc_t** shared;
int rc;
fixture_init(&core, &g_full_plugin);
job1 = make_job(71, JOB_STAT_PEND, "normal", "alice", "projA");
memset(&event, 0, sizeof(event));
event.type = XCE_CORE_EVENT_JOBS_PUSH;
event.u.jobs_push.njobs = 1;
event.u.jobs_push.jobs = &job1;
rc = xce_core_push_events(&core, &event, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
job = sched_get_job(&core, 71);
TEST_ASSERT_NOT_NULL(job);
/* the exact shape the controller re-sends post-fix: per-host slots +
* two shared records -- the limit vres instance charge and the
* underlying EXTERNAL pool draw. */
a_slots = make_alloc((char*)"slots", NULL, 0, 1.0f, 0);
h0 = make_host_alloc((char*)"hostC", 1, make_allocs(1, a_slots));
hallocs[0] = h0;
jalloc = make_job_alloc(71, 1, make_host_allocs(1, hallocs[0]));
shared = make_allocs(2,
make_alloc((char*)"limit=extlic_share/p=projA", NULL, 0, 1.0f, 0),
make_alloc((char*)"extlic", NULL, 0, 1.0f, 0));
TEST_ASSERT_NOT_NULL(shared);
jalloc->allocs_shared = shared;
jalloc->nres_shared = 2;
memset(&updated, 0, sizeof(updated));
updated.jobid = 71;
updated.state = JOB_STAT_RUN;
updated.allocs = jalloc;
memset(&event, 0, sizeof(event));
event.type = XCE_CORE_EVENT_JOB_UPDATE;
event.u.job_update.jobid = 71;
event.u.job_update.job = &updated;
rc = xce_core_push_events(&core, &event, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, job->state);
TEST_ASSERT_NOT_NULL(job->allocs);
/* the accounts the charge loop walks must mirror the shared records --
* this is the exact property bug-1976's controller drop destroyed */
TEST_ASSERT_NOT_NULL(job->accounts);
TEST_ASSERT_EQUAL_INT_MESSAGE(2, job->accounts->nres_shared,
"adoption must rebuild accounts WITH the shared records -- without "
"them sched_account_jobs() never charges the vres and every global "
"limit admits forever (bug-1976)");
TEST_ASSERT_NOT_NULL(job->accounts->accounts_shared);
TEST_ASSERT_EQUAL_STRING("limit=extlic_share/p=projA",
job->accounts->accounts_shared[0]->resname);
TEST_ASSERT_EQUAL_FLOAT(1.0f,
job->accounts->accounts_shared[0]->reserved);
TEST_ASSERT_EQUAL_STRING("extlic",
job->accounts->accounts_shared[1]->resname);
TEST_ASSERT_EQUAL_FLOAT(1.0f,
job->accounts->accounts_shared[1]->reserved);
/* allocation ownership moves; the job's own records survive intact */
TEST_ASSERT_NULL(updated.allocs);
TEST_ASSERT_EQUAL_INT(2, job->allocs->nres_shared);
TEST_ASSERT_EQUAL_STRING("limit=extlic_share/p=projA",
job->allocs->allocs_shared[0]->resname);
}
/* Companion: a resync claiming a genuinely TERMINAL state (DONE/EXIT) for a
* RUN-with-real-allocation job must still apply -- this is the legitimate
* "job finished while this scheduler was disconnected" case bug-1734's own
@ -2147,6 +2240,7 @@ int main(void)
RUN_TEST(test_jobs_push_duplicate_jobid_run_state_transfers_allocation);
RUN_TEST(test_jobs_push_duplicate_jobid_controller_pend_wins_over_local_run);
RUN_TEST(test_job_update_run_adopts_controller_allocation);
RUN_TEST(test_job_update_run_adoption_carries_shared_allocs);
RUN_TEST(test_jobs_push_duplicate_jobid_terminal_resync_releases_real_allocation);
RUN_TEST(test_jobs_push_duplicate_jobid_run_state_without_allocation_is_refused);
RUN_TEST(test_jobs_push_duplicate_jobid_does_not_regress_terminal_state);

View file

@ -270,8 +270,16 @@ static xce_limit_config_t* make_project_limit(const char* name,
static void free_limit_config(xce_limit_config_t* cfg)
{
FREEUP(cfg->val);
FREEUP(cfg->projects->members.data);
FREEUP(cfg->projects);
/* per_queue configs carry the scope in queues and NULL projects (the
* per_project shape is the mirror image) -- either group is optional */
if (cfg->projects != NULL) {
FREEUP(cfg->projects->members.data);
FREEUP(cfg->projects);
}
if (cfg->queues != NULL) {
FREEUP(cfg->queues->members.data);
FREEUP(cfg->queues);
}
FREEUP(cfg);
}
@ -462,6 +470,97 @@ static void test_cap_value_and_per_project_instance_names(void)
free_limit_config(limits[1]);
}
/* ---------------------------------------------------------------------- */
/* 2b. The per-queue distribution shape (limits.yaml's extlic_qdist): a
* queues:-scoped limit with per_queue slices its usage instance by queue
* ("limit=<name>/q=<queue>") exactly as the projects shape slices by
* project ("/p=") -- how a site partitions one license pool across
* queues. Also pins that the queue scope does NOT match a project-only
* identity: the consumer matched is the obj's queue, so a different
* queue outside the members list matches nothing. */
static void test_queue_scope_per_queue_instance_names(void)
{
xce_scheduler_t sched;
xce_sched_plugin_t plugin;
xce_sched_obj_t obj;
xce_limit_config_t* limits[1];
xce_limit_config_t* cfg;
xce_group_config_t* qg;
xce_vres_t* vres;
const char* const scope_a[] = { "projA" };
const char* const queues[] = { "normal", "night" };
char** qmembers;
char name[64];
int rc;
int i;
fixture_init(&sched, &plugin);
register_resource(&(sched.core), "extlic",
XCE_RESF_SHARED | XCE_RESF_GLOBAL | XCE_RESF_EXTERNAL);
limits[0] = make_project_limit("extlic_qdist", "extlic", 4.0f, scope_a,
1);
cfg = limits[0];
FREEUP(cfg->projects->members.data);
FREEUP(cfg->projects);
cfg->projects = NULL;
cfg->per_queue = 1;
/* queue scope: heap group config, same lifetime contract as the
* project one make_project_limit() builds */
qg = (xce_group_config_t*)calloc(1, sizeof(*qg));
qmembers = (char**)calloc(2, sizeof(char*));
TEST_ASSERT_NOT_NULL(qg);
TEST_ASSERT_NOT_NULL(qmembers);
for (i = 0; i < 2; i++) {
qmembers[i] = (char*)queues[i];
}
qg->members.count = 2;
qg->members.data = qmembers;
cfg->queues = qg;
TEST_ASSERT_EQUAL_INT(0, push_limits(&plugin, limits, 1));
/* each in-scope queue gets its OWN /q= instance */
make_obj(&obj, "projA");
obj.queue = makeString("normal");
rc = match_global(&plugin, &obj, find_limit(&plugin, "extlic_qdist"));
TEST_ASSERT_GREATER_OR_EQUAL_INT(0, rc);
snprintf(name, sizeof(name), "limit=extlic_qdist/q=normal");
vres = find_instance(&plugin, name);
TEST_ASSERT_NOT_NULL_MESSAGE(vres,
"per_queue must slice the usage instance by queue (/q= segment), "
"the queue analogue of the /p= project shape");
TEST_ASSERT_EQUAL_INT(1, obj.glimits.count);
TEST_ASSERT_EQUAL_PTR(vres, xlist_get(&(obj.glimits), 0));
/* cap 4.0 written at this instance's own vres idx */
{
xce_val_t* v = (xce_val_t*)zarray_get(&(sched.core.sharedInst),
vres->vres->idx);
TEST_ASSERT_NOT_NULL(v);
TEST_ASSERT_EQUAL_FLOAT(4.0f, v->u.fvalue);
}
make_obj(&obj, "projA");
obj.queue = makeString("night");
rc = match_global(&plugin, &obj, find_limit(&plugin, "extlic_qdist"));
TEST_ASSERT_GREATER_OR_EQUAL_INT(0, rc);
snprintf(name, sizeof(name), "limit=extlic_qdist/q=night");
TEST_ASSERT_NOT_NULL(find_instance(&plugin, name));
/* out-of-scope queue matches nothing */
make_obj(&obj, "projA");
obj.queue = makeString("short");
rc = match_global(&plugin, &obj, find_limit(&plugin, "extlic_qdist"));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, obj.glimits.count);
free_limit_config(limits[0]);
}
/* ---------------------------------------------------------------------- */
/* 3. The unknown-underlying-resource failure is a clean -ENOENT: a limit
* naming a resource the core never registered must not crash and must
@ -502,6 +601,7 @@ int main(void)
UNITY_BEGIN();
RUN_TEST(test_project_scope_match_and_instantiate);
RUN_TEST(test_cap_value_and_per_project_instance_names);
RUN_TEST(test_queue_scope_per_queue_instance_names);
RUN_TEST(test_unknown_underlying_resource_fails_clean);
rc = UNITY_END();