diff --git a/src/scheduler/testsuite/test_core_events.c b/src/scheduler/testsuite/test_core_events.c index af49c5a3..bb89310e 100644 --- a/src/scheduler/testsuite/test_core_events.c +++ b/src/scheduler/testsuite/test_core_events.c @@ -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); diff --git a/src/scheduler/testsuite/test_limit_project_scope.c b/src/scheduler/testsuite/test_limit_project_scope.c index 1f01a639..35393f66 100644 --- a/src/scheduler/testsuite/test_limit_project_scope.c +++ b/src/scheduler/testsuite/test_limit_project_scope.c @@ -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=/q=") 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();