diff --git a/src/configs/limits.yaml b/src/configs/limits.yaml index 37ec6ab0..a10d16c9 100644 --- a/src/configs/limits.yaml +++ b/src/configs/limits.yaml @@ -93,3 +93,35 @@ limits: resname: mem value: 0.2 percent: true + + # external-license-resources (E2 Phase 3): strategy-layer quotas on the + # externally reported extlic pool. These ride the EXISTING limit vres + # mechanism -- no new daemon, no new admission rule -- on top of Phase + # 1's underlying-resource check (total >= uncomit + wanted). Two + # distribution shapes: + # + # 1. per-project shares: projA and projB each hold their own + # "limit=extlic_share/p=" usage instance under a 3.0 cap + # (independent accounting per project; the scope lists who may + # match at all). literal members here; named project groups are + # project-config territory (out of this spec's scope). + - name: extlic_share + projects: + members: + - projA + - projB + resname: extlic + value: 3.0 + per_project: true + + # 2. per-queue distribution: queues each get their own + # "limit=extlic_qdist/q=" instance under a 4.0 cap -- how a + # site partitions one license pool across queues. + - name: extlic_qdist + queues: + members: + - normal + - night + resname: extlic + value: 4.0 + per_queue: true diff --git a/src/controller/job.c b/src/controller/job.c index 294ea735..6b45e8b0 100644 --- a/src/controller/job.c +++ b/src/controller/job.c @@ -1936,6 +1936,19 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc) MOVE_PTR(j->job.allocs->allocs, allocs); alloc->allocs = NULL; + /* external-license-resources Phase 3: the scheduler's dispatch + * payload also carries the shared-pool records (allocs_shared -- the + * per-project/per-queue limit vres charge among them). This rebuild + * is the record sched_job_update_fun() re-sends to the scheduler as + * ground truth (the JOB_UPDATE adoption there rebuilds job->accounts + * from it), so dropping the shared records here starves the + * scheduler's sharedInUse accounting and every global limit admits + * forever. Migrate them (MOVE, not copy -- the caller frees the + * payload) exactly like the per-host records above. */ + MOVE_PTR(j->job.allocs->allocs_shared, alloc->allocs_shared); + j->job.allocs->nres_shared = alloc->nres_shared; + alloc->nres_shared = 0; + j->job.state = JOB_STAT_RUN; /* bug-1697: start_time is now stamped HERE, synchronously alongside diff --git a/src/controller/testsuite/test_dispatch_commit.c b/src/controller/testsuite/test_dispatch_commit.c index c50650f9..58c2de3e 100644 --- a/src/controller/testsuite/test_dispatch_commit.c +++ b/src/controller/testsuite/test_dispatch_commit.c @@ -393,6 +393,78 @@ static void test_dc_success_notifies_job_update_hook_exactly_once(void) printf("test_dc_success_notifies_job_update_hook_exactly_once: PASS\n"); } +#define DC_JOBID_SHARED 91006ULL + +/* external-license-resources Phase 3 (vres charge) RED/GREEN proof: the + * scheduler's dispatch payload (sched_finalize_job_alloc_, alloc_runner.c) + * carries BOTH the per-host records AND the shared-pool records + * (allocs_shared -- the vres instance "limit=extlic_share/p=projA" charge + * among them). The controller's dispatch-time commit (job.c) rebuilds + * j->job.allocs from that payload, and that rebuilt record is what + * sched_job_update_fun() re-sends to the scheduler as ground truth + * (bug-1860 adoption in the scheduler's JOB_UPDATE handler rebuilds + * job->accounts from it) -- so if the rebuild drops allocs_shared, the + * scheduler's accounts lose every shared record, sharedInUse never + * accumulates the vres charge, and unsatisfy_ admits past the limit + * forever. + * + * RED against the pre-fix code: j->job.allocs->nres_shared stays 0 and + * allocs_shared stays NULL (the rebuild MOVE_PTRs only allocs->allocs). + * GREEN after the fix: the shared records are migrated intact (count, + * names, values), and the payload's own shared pointer is moved out (no + * double free). */ +static void test_dc_success_migrates_shared_allocs(void) +{ + job_t* j; + xce_job_alloc_t* alloc; + xce_alloc_t** shared; + int rc; + + dc_setup(); + + j = dc_seed_ready(DC_JOBID_SHARED, 0, "dq", "wkr-dc-shared"); + + alloc = make_job_alloc(DC_JOBID_SHARED, 1, + make_host_allocs(1, + make_host_alloc((char*)"wkr-dc-shared", 1, + make_allocs(1, + make_alloc((char*)"slots", NULL, 0, 1.0f, 0))))); + TEST_ASSERT_NOT_NULL(alloc); + + 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); + alloc->allocs_shared = shared; + alloc->nres_shared = 2; + + rc = dispatch_a_job_test(&g_dc, NULL, alloc); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, + "a dispatch to a live, no-iomgr worker endpoint must succeed"); + + TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, j->job.state); + TEST_ASSERT_NOT_NULL(j->job.allocs); + TEST_ASSERT_EQUAL_INT_MESSAGE(2, j->job.allocs->nres_shared, + "the dispatch-time rebuild must migrate the payload's shared-pool " + "records -- dropping them starves the scheduler's vres usage " + "accounting (sharedInUse) and defeats every global limit"); + TEST_ASSERT_NOT_NULL(j->job.allocs->allocs_shared); + TEST_ASSERT_EQUAL_STRING("limit=extlic_share/p=projA", + j->job.allocs->allocs_shared[0]->resname); + TEST_ASSERT_EQUAL_FLOAT(1.0f, j->job.allocs->allocs_shared[0]->value); + TEST_ASSERT_EQUAL_STRING("extlic", + j->job.allocs->allocs_shared[1]->resname); + TEST_ASSERT_EQUAL_FLOAT(1.0f, j->job.allocs->allocs_shared[1]->value); + TEST_ASSERT_NULL_MESSAGE(alloc->allocs_shared, + "the shared records must be MOVE_PTR'd out of the payload, not " + "copied -- a copy would double-free when the caller frees it"); + + free_JobAlloc_ptr(&alloc); + dc_drain_ep(&g_dc_worker_ep); + dc_teardown(); + printf("test_dc_success_migrates_shared_allocs: PASS\n"); +} + #define DC_JOBID_NOTFOUND 91002ULL /* Regression safety net (untouched failure branch): a dispatch whose @@ -801,6 +873,7 @@ void test_dispatch_commit(void) RUN_TEST(test_dc_success_commits_alloc_state_and_resets_counter); RUN_TEST(test_dc_success_stamps_start_time_synchronously); RUN_TEST(test_dc_success_notifies_job_update_hook_exactly_once); + RUN_TEST(test_dc_success_migrates_shared_allocs); RUN_TEST(test_dc_worker_not_found_leaves_job_pend_and_alloc_uncommitted); RUN_TEST(test_dc_success_appends_wal_job_dispatch_durably); } diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index cb4f1c6e..5d78725c 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -1634,6 +1634,23 @@ sched_limit_plugin_test = executable('sched_limit_plugin_test', dependencies: [scheduler_test_deps], install: false) +# external-license-resources Phase 3 (LSF 策略层): white-box regression +# coverage for the "GLOBAL limit on a GLOBAL|SHARED|EXTERNAL resource" +# shape (the per-project/per-queue/per-user license-quota mapping onto the +# existing limit vres mechanism). It #includes ../src/plugins/limit.c + +# limit_config.c directly (same convention as sched_limit_plugin_test +# above) so it drives the real match_job_global_limit_() -- `static`, +# otherwise unreachable. Unlike test_limit_plugin.c, its get_resvals()/ +# put_resvals() are FAITHFUL COPIES of resource.c's (the value path is the +# subject), and the fixture carries a real core->resmgr + +# sharedInst/sharedInUse -- see the file's own header comment. +sched_limit_project_scope_test = executable( + 'sched_limit_project_scope_test', + ['test_limit_project_scope.c'], + include_directories:[incdir], + dependencies: [scheduler_test_deps], + install: false) + # White-box regression test for bug-1686 (live SIGSEGV in # fcfs_cursor_enter_queue() <- builtin_fcfs_next_job(), a heap-use-after-free # on priv->cursor/job_cursor -- raw ordq_item_t* values that persist across @@ -2070,6 +2087,8 @@ test('scheduler snapshot load allocator regression test', env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler limit plugin test', sched_limit_plugin_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) +test('scheduler limit project scope test', sched_limit_project_scope_test, + suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler builtin fcfs cursor uaf test', sched_builtin_fcfs_cursor_uaf_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/scheduler/testsuite/test_limit_project_scope.c b/src/scheduler/testsuite/test_limit_project_scope.c new file mode 100644 index 00000000..1f01a639 --- /dev/null +++ b/src/scheduler/testsuite/test_limit_project_scope.c @@ -0,0 +1,511 @@ +/* + * test_limit_project_scope.c - external-license-resources Phase 3 (LSF + * 策略层, design.md 3.5): per-project/per-queue/per-user token distribution + * maps onto the EXISTING limit vres mechanism. A limit on a GLOBAL + * underlying resource (e.g. the extlic license pool, SHARED|GLOBAL| + * EXTERNAL per mapping.py's location: [external] branch) with a projects: + * scope must: + * + * 1. match jobs by their obj->project string (literal project members -- + * make_g_member()'s literal fallback; named project groups are + * project-config's territory, deliberately not exercised here); + * 2. intern a usage instance named "limit=" (no /p= -- per_project + * false means one shared pool for all members of the project scope), + * or "limit=/p=" when per_project is set (independent + * usage per project under one configured cap); + * 3. write its cap into core->sharedInst at the vres's own idx + * (update_limit_res_value()); + * 4. take the matched vres into obj->glimits (match_job_global_limit_ + * -> xlist_store), so sched_fill_limit_requests() (resource.c) can + * stage the job's want into alloc_req.shared and the generic satisfy + * check can compare want+uncomit against the cap like any resource. + * + * The mechanical shape match_job_global_limit_ needs from the core: + * - core->resmgr: must already contain the UNDERLYING resource named by + * limit->val.resname (instantiate_limit() returns -ENOENT otherwise) + * AND grows the limit's vres via add_vres() -- exactly what + * sched_init_resources() does live; + * - core->sharedInst: the cap's zarray (update_limit_res_value()'s + * put_resvals target); + * - core->sharedInUse: where the generic check reads RUN jobs' usage + * (init here for parity; not touched by the plugin). + * + * White-box: #include's the real ../src/plugins/limit.c + + * limit_config.c (same convention as test_limit_plugin.c, whose fixture + * this file mirrors -- real group managers, real plugin private state). + * match_job_global_limit_() is `static`, otherwise unreachable outside + * its TU. get_resvals()/put_resvals() are NOT no-op stubs here (unlike + * test_limit_plugin.c, whose tests never exercise the value path): the + * value path IS this file's subject, so both are faithful copies of + * resource.c's (that file is not part of this TU; the copies preserve the + * production zarray protocol -- xce_val_t entries in Value_u_fvalue form, + * created zero-filled on first put, read as 0.0 when absent) so the + * fixture's sharedInst ends in the same state production would leave, + * and the cap assertions below prove the plugin put the RIGHT value at + * the RIGHT idx, not merely that it called something. + */ +#include +#include +#include + +#include + +#include "group.h" +#include "kvmgr.h" +#include "scheduler.h" +#include "xce/types.h" +#include "xcelimit.h" +#include "xstring.h" + +/* Faithful copies of resource.c's get_resvals()/put_resvals() (see file + * header). The plugin always calls them with nres == 1, so the copies + * need no dedup-by-duplicate-idx handling (resource.c's bug-1569 block is + * dead weight at nres 1). */ +int get_resvals(zarray_t* array, int nres, int* idxs, float* vals) +{ + xce_val_t* xvals[1]; + int rc; + + if ((array == NULL) || (nres < 0) || (idxs == NULL) || (vals == NULL)) { + return -EINVAL; + } + + if (nres == 0) { + return 0; + } + + memset(vals, 0x0, sizeof(*vals) * nres); + + rc = zarray_get_ex(array, nres, idxs, (void**)xvals); + if (rc < 0) { + return rc; + } + + if ((xvals[0] != NULL) && (xvals[0]->which == Value_u_fvalue)) { + vals[0] = xvals[0]->u.fvalue; + } + + return nres; +} + +int put_resvals(zarray_t* array, int nres, int* idxs, float* vals) +{ + xce_val_t* xvals[1]; + int rc; + + if ((array == NULL) || (nres < 0) || (idxs == NULL) || (vals == NULL)) { + return -EINVAL; + } + + if (nres == 0) { + return 0; + } + + rc = zarray_get_ex(array, nres, idxs, (void**)xvals); + if (rc < 0) { + return rc; + } + + if (idxs[0] < 0) { + return nres; + } + + if (xvals[0] == NULL) { + xce_val_t* val = (xce_val_t*)calloc(1, sizeof(*val)); + if (val == NULL) { + return -ENOMEM; + } + + val->which = Value_u_fvalue; + val->u.fvalue = 0.0; + zarray_put(array, idxs[0], val); + xvals[0] = val; + } + + xvals[0]->u.fvalue = vals[0]; + + return nres; +} + +/* limit_job_election() calls reset_sched_obj() on the REAL schedobj; + * this TU drives match_job_global_limit_() directly, so a stub purely + * for the linker -- same as test_limit_plugin.c. */ +void reset_sched_obj(xce_sched_obj_t* obj) +{ + (void)obj; +} + +int plugin_register(void* sched, void* plugin) +{ + (void)sched; + (void)plugin; + return 0; +} + +#include "../src/plugins/limit.c" +#include "../src/plugins/limit_config.c" + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* Same fixture as test_limit_plugin.c's fixture_init(): real group + * managers for all four consumer kinds (build_limit()'s build_consumer() + * resolves each consumer against its own manager -- the "all" group for + * any scope the config leaves NULL), plus a real limit plugin private + * state. plugin->core is the bug-1964 honest reference. Extended here + * with the two pieces production's xce_sched_core_init() builds and the + * plugin's value path touches: core->resmgr (a kvmgr with no datakiller + * -- suite convention, detect_leaks=0, no teardown) and the + * sharedInst/sharedInUse zarays. */ +static void fixture_init(xce_scheduler_t* sched, xce_sched_plugin_t* plugin) +{ + kvmgr_params_t resmgr_params = { .key_type = KVMGR_KEY_STRING, + .nslots = 8, + .delta = 4, + .datakiller = NULL }; + int rc; + + memset(sched, 0, sizeof(*sched)); + + sched->core.resmgr = makeInstance(&xkvmgr_type, &resmgr_params); + TEST_ASSERT_NOT_NULL(sched->core.resmgr); + + groupmgr_init(&(sched->core.ugmgr), 4, 4); + groupmgr_init(&(sched->core.qgmgr), 4, 4); + groupmgr_init(&(sched->core.pgmgr), 4, 4); + groupmgr_init(&(sched->core.hgmgr), 4, 4); + TEST_ASSERT_NOT_NULL(sched->core.ugmgr); + TEST_ASSERT_NOT_NULL(sched->core.qgmgr); + TEST_ASSERT_NOT_NULL(sched->core.pgmgr); + TEST_ASSERT_NOT_NULL(sched->core.hgmgr); + + zarray_init(&(sched->core.sharedInst), 8, NULL); + zarray_init(&(sched->core.sharedInUse), 8, NULL); + + memset(plugin, 0, sizeof(*plugin)); + plugin->sched = sched; + plugin->core = &(sched->core); + + rc = limit_init(plugin); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(plugin->data); +} + +/* sched_init_resources()'s role, reduced to what the limit plugin needs: + * the UNDERLYING resource must exist in core->resmgr before the first + * match instantiates the vres (instantiate_limit()'s key_find of + * limit->val.resname returns -ENOENT otherwise -- the "unknown resource" + * failure mode test 3 pins). `flags` picks the underlying resource's own + * nature: SHARED|GLOBAL|EXTERNAL is the exact shape mapping.py's + * location: [external] branch produces for extlic. NOTE: makeResource_ + * assigns the resource's idx itself (the kvmgr slot), so the caller's + * wire.idx is ignored -- tests must read idx back, never assume it. */ +static xce_resource_t* register_resource(xce_sched_core_t* core, + const char* name, int flags) +{ + xce_resource_t wire; + xce_resource_t* made = NULL; + + memset(&wire, 0, sizeof(wire)); + wire.name = (char*)name; + wire.flags = flags; + + made = (xce_resource_t*)makeResource(core->resmgr, &wire); + TEST_ASSERT_NOT_NULL(made); + + return made; +} + +/* push limit configs through the plugin's config hook (both drivers do + * this -- plugin_config_update() live, plugin_config_update_core() sim) */ +static int push_limits(xce_sched_plugin_t* plugin, xce_limit_config_t** limits, + int n) +{ + xce_conf4sched_t config; + + memset(&config, 0, sizeof(config)); + config.nlimits = n; + config.limits = limits; + + return limit_update_config(plugin, &config); +} + +/* a heap limit config: scope = literal project members (heap group config + * -- the plugin's built limit references the group config's members for + * the consumer group's lifetime), absolute value cap */ +static xce_limit_config_t* make_project_limit(const char* name, + const char* resname, float cap, + const char* const* projects, + int nprojects) +{ + xce_limit_config_t* cfg = (xce_limit_config_t*)calloc(1, sizeof(*cfg)); + xce_limit_val_t* val = (xce_limit_val_t*)calloc(1, sizeof(*val)); + xce_group_config_t* g = (xce_group_config_t*)calloc(1, sizeof(*g)); + char** members = (char**)calloc((size_t)nprojects, + sizeof(char*)); + int i; + + TEST_ASSERT_NOT_NULL(cfg); + TEST_ASSERT_NOT_NULL(val); + TEST_ASSERT_NOT_NULL(g); + TEST_ASSERT_NOT_NULL(members); + + val->resname = (char*)resname; + val->percent = 0; + val->val = cap; + + for (i = 0; i < nprojects; i++) { + members[i] = (char*)projects[i]; + } + g->members.count = nprojects; + g->members.data = members; + + cfg->name = (char*)name; + cfg->val = val; + cfg->projects = g; + + return cfg; +} + +static void free_limit_config(xce_limit_config_t* cfg) +{ + FREEUP(cfg->val); + FREEUP(cfg->projects->members.data); + FREEUP(cfg->projects); + FREEUP(cfg); +} + +static xce_limit_t* find_limit(xce_sched_plugin_t* plugin, const char* name) +{ + limit_plugin_data_t* privdata = (limit_plugin_data_t*)plugin->data; + xce_limit_t* limit = NULL; + + key_find(privdata->limits, (void*)name, (void**)&limit); + + return limit; +} + +static xce_vres_t* find_instance(xce_sched_plugin_t* plugin, const char* name) +{ + limit_plugin_data_t* privdata = (limit_plugin_data_t*)plugin->data; + xce_vres_t* vres = NULL; + + key_find(privdata->instances, (void*)name, (void**)&vres); + + return vres; +} + +/* a minimal xce_sched_obj_t: project identity + the glimits xlist the + * plugin stores matched vres into. make_sched_obj() (schedobj.c) builds + * the real one; its project normalization ("default" for empty) is + * core_events.c's job, and the plugin consumes the result as-is -- so the + * fixture hands obj->project the string the scheduler would have. */ +static void make_obj(xce_sched_obj_t* obj, const char* project) +{ + memset(obj, 0, sizeof(*obj)); + obj->user = makeString("user1"); + obj->queue = makeString("normal"); + obj->project = makeString((char*)project); + xlist_init(&(obj->glimits), 8); +} + +/* drive match_job_global_limit_() the way limit_job_election() does: + * throwaway ctx, only ctx.schedobj is read. */ +static int match_global(xce_sched_plugin_t* plugin, xce_sched_obj_t* obj, + xce_limit_t* limit) +{ + xce_sched_ctx_t ctx = { 0 }; + + ctx.schedobj = obj; + + return match_job_global_limit_(plugin, &ctx, limit); +} + +/* ---------------------------------------------------------------------- */ +/* 1. A project-scoped limit on a GLOBAL|SHARED|EXTERNAL resource matches + * by obj->project (literal member), is global (lands in glimits -- + * is_host_based_limit() keys on the HOST consumer, never the underlying + * resource's own GLOBAL flag), and instantiates the usage instance named + * "limit=" (per_project false -> one shared pool for the project + * scope; no /p= segment). Members outside the scope match nothing and + * store nothing. */ +static void test_project_scope_match_and_instantiate(void) +{ + xce_scheduler_t sched; + xce_sched_plugin_t plugin; + xce_sched_obj_t obj; + xce_limit_config_t* limits[1]; + xce_limit_t* limit; + xce_vres_t* vres; + const char* const scope_a[] = { "projA" }; + char vresname[64]; + int rc; + + fixture_init(&sched, &plugin); + + /* the extlic shape: SHARED (cluster pool) + GLOBAL (admitted in the + * shared leg) + EXTERNAL (free-value reporting; Phase 1's unsatisfy_ + * rule -- asserted independently in testsuite/test.c). */ + register_resource(&(sched.core), "extlic", + XCE_RESF_SHARED | XCE_RESF_GLOBAL | XCE_RESF_EXTERNAL); + + limits[0] = make_project_limit("extlic_projA", "extlic", 2.0f, scope_a, 1); + TEST_ASSERT_EQUAL_INT(0, push_limits(&plugin, limits, 1)); + + limit = find_limit(&plugin, "extlic_projA"); + TEST_ASSERT_NOT_NULL(limit); + + /* in-scope project matches. match_job_global_limit_()'s own rc + * contract is "0 on success" (its tail rc = xlist_store() returns the + * stored idx -- 0 for the first entry; only < 0 is failure, the same + * rc<0 break limit_job_election() applies), so the success signal is + * rc >= 0 AND the vres landing in obj.glimits -- asserted below. */ + make_obj(&obj, "projA"); + rc = match_global(&plugin, &obj, limit); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, rc); + + /* the instance is keyed by the bare vres name (per_project false) */ + snprintf(vresname, sizeof(vresname), "limit=extlic_projA"); + vres = find_instance(&plugin, vresname); + TEST_ASSERT_NOT_NULL(vres); + TEST_ASSERT_EQUAL_PTR(limit, vres->limit); + TEST_ASSERT_NOT_NULL(vres->vres); + TEST_ASSERT_EQUAL_STRING(vresname, vres->vres->name); + /* the vres is a REAL resource now: SHARED|GLOBAL (its own nature, from + * add_vres()'s `shared` flag derived from is_host_based_limit()) + + * LIMIT (the satisfy check's PREASON_LIMIT_GLOBAL discriminator). */ + TEST_ASSERT_TRUE((vres->vres->flags & XCE_RESF_LIMIT) != 0); + TEST_ASSERT_TRUE((vres->vres->flags & XCE_RESF_SHARED) != 0); + TEST_ASSERT_TRUE((vres->vres->flags & XCE_RESF_GLOBAL) != 0); + /* the underlying resource is linked */ + TEST_ASSERT_NOT_NULL(vres->res); + TEST_ASSERT_EQUAL_STRING("extlic", vres->res->name); + + /* the matched vres was stored into obj->glimits for + * sched_fill_limit_requests() to stage */ + TEST_ASSERT_EQUAL_INT(1, obj.glimits.count); + TEST_ASSERT_EQUAL_PTR(vres, xlist_get(&(obj.glimits), 0)); + + /* out-of-scope project: no match, nothing stored */ + make_obj(&obj, "projB"); + rc = match_global(&plugin, &obj, limit); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_EQUAL_INT(0, obj.glimits.count); + + free_limit_config(limits[0]); +} + +/* ---------------------------------------------------------------------- */ +/* 2. The cap lands in core->sharedInst at the vres's own idx, and the + * per_project variant slices by project ("limit=/p="): two + * projects get independent usage entries under one configured cap. */ +static void test_cap_value_and_per_project_instance_names(void) +{ + xce_scheduler_t sched; + xce_sched_plugin_t plugin; + xce_sched_obj_t obj; + xce_limit_config_t* limits[2]; + xce_limit_t* shared_pool; + xce_limit_t* per_project; + xce_vres_t* vres; + xce_val_t* v; + const char* const scope_a[] = { "projA" }; + const char* const scope_ab[] = { "projA", "projB" }; + char name[64]; + int rc; + + fixture_init(&sched, &plugin); + + register_resource(&(sched.core), "extlic", + XCE_RESF_SHARED | XCE_RESF_GLOBAL | XCE_RESF_EXTERNAL); + + limits[0] = make_project_limit("extlic_pool", "extlic", 2.0f, scope_a, 1); + /* per-project slicing needs BOTH projects in the limit's scope: the + * scope decides who matches at all; per_project then splits each + * match into its own "limit=/p=" usage instance. */ + limits[1] = make_project_limit("extlic_share", "extlic", 3.0f, scope_ab, + 2); + limits[1]->per_project = 1; + TEST_ASSERT_EQUAL_INT(0, push_limits(&plugin, limits, 2)); + + /* shared-pool limit: cap written at the instance's own vres idx */ + shared_pool = find_limit(&plugin, "extlic_pool"); + TEST_ASSERT_NOT_NULL(shared_pool); + make_obj(&obj, "projA"); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, match_global(&plugin, &obj, + shared_pool)); + + vres = (xce_vres_t*)xlist_get(&(obj.glimits), 0); + TEST_ASSERT_NOT_NULL(vres); + v = (xce_val_t*)zarray_get(&(sched.core.sharedInst), vres->vres->idx); + TEST_ASSERT_NOT_NULL(v); + TEST_ASSERT_EQUAL_FLOAT(2.0f, v->u.fvalue); + + /* per-project limit: /p= slices the name, so projA and projB get + * independent instances (independent usage) under one cap */ + per_project = find_limit(&plugin, "extlic_share"); + TEST_ASSERT_NOT_NULL(per_project); + + make_obj(&obj, "projA"); + rc = match_global(&plugin, &obj, per_project); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, rc); + make_obj(&obj, "projB"); + rc = match_global(&plugin, &obj, per_project); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, rc); + + snprintf(name, sizeof(name), "limit=extlic_share/p=projA"); + TEST_ASSERT_NOT_NULL(find_instance(&plugin, name)); + snprintf(name, sizeof(name), "limit=extlic_share/p=projB"); + TEST_ASSERT_NOT_NULL(find_instance(&plugin, name)); + + free_limit_config(limits[0]); + free_limit_config(limits[1]); +} + +/* ---------------------------------------------------------------------- */ +/* 3. The unknown-underlying-resource failure is a clean -ENOENT: a limit + * naming a resource the core never registered must not crash and must + * leave nothing in obj->glimits (the failure mode instantiate_limit()'s + * own comment warns of -- the resname must exist before the first match). */ +static void test_unknown_underlying_resource_fails_clean(void) +{ + xce_scheduler_t sched; + xce_sched_plugin_t plugin; + xce_sched_obj_t obj; + xce_limit_config_t* limits[1]; + const char* const scope_a[] = { "projA" }; + int rc; + + fixture_init(&sched, &plugin); + /* deliberately NO resource registered */ + + limits[0] = make_project_limit("ghost_limit", "ghost_lic", 2.0f, scope_a, + 1); + TEST_ASSERT_EQUAL_INT(0, push_limits(&plugin, limits, 1)); + TEST_ASSERT_NOT_NULL(find_limit(&plugin, "ghost_limit")); + + make_obj(&obj, "projA"); + rc = match_global(&plugin, &obj, find_limit(&plugin, "ghost_limit")); + TEST_ASSERT_EQUAL_INT(-ENOENT, rc); + TEST_ASSERT_EQUAL_INT(0, obj.glimits.count); + + free_limit_config(limits[0]); +} + +int main(void) +{ + int rc; + + /* group names are interned strings (makeString(), group.c) */ + xstring_table_init(139); + + UNITY_BEGIN(); + RUN_TEST(test_project_scope_match_and_instantiate); + RUN_TEST(test_cap_value_and_per_project_instance_names); + RUN_TEST(test_unknown_underlying_resource_fails_clean); + rc = UNITY_END(); + + xstring_table_fini(); + + return rc; +}