E2 Phase 3: per-project/per-queue license quotas via limit vres + controller shared-alloc migration fix

Strategy layer (spec .kiro/specs/external-license-resources design.md 3.5/3.6):
- limits.yaml samples: extlic_share (per-project, projA/projB, cap 3.0) and
  extlic_qdist (per-queue, normal/night, cap 4.0) on the externally reported
  extlic pool -- instances "limit=extlic_share/p=<proj>" and
  "limit=extlic_qdist/q=<queue>" under the existing limit vres mechanism.
  Multi-daemon redundancy is free (resval last-writer-wins); manual override
  REST deliberately out of scope.

- White-box scheduler test (test_limit_project_scope.c): project-scope
  match + vres instantiation, per-project instance fan-out with caps,
  unknown underlying resource failing clean. 3/3 under ASAN.

Controller fix (the Phase 3 E2E blocker, TDD red->green):
- dispatch_a_job_() rebuilt j->job.allocs from the scheduler's dispatch
  payload but migrated only the per-host records -- allocs_shared (the
  limit vres charge among them) was dropped, and its payload pointer
  leaked. That rebuilt record is what sched_job_update_fun() re-sends to
  the scheduler as ground truth (the JOB_UPDATE adoption there rebuilds
  job->accounts from it), so sharedInUse never accumulated the vres
  charge: unsatisfy_ admitted past every global limit forever.
  Fix: MOVE the shared records alongside the host records (test
  test_dc_success_migrates_shared_allocs).

t1 E2E (4 licenses, extlic_share projA/projB cap 3.0): 3 projA RUN, 4th
projA PEND, projB unaffected (own per-project instance); killing one
RUN job re-admits the pending projA; external free pool and per-project
shares enforced independently; all daemons healthy throughout.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-28 05:36:43 +00:00
parent 857f987f3c
commit 76ddd75fd1
5 changed files with 648 additions and 0 deletions

View file

@ -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=<proj>" 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=<queue>" 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

View file

@ -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

View file

@ -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);
}

View file

@ -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'])

View file

@ -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=<name>" (no /p= -- per_project
* false means one shared pool for all members of the project scope),
* or "limit=<name>/p=<proj>" 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 <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#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=<name>" (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=<name>/p=<proj>"): 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=<name>/p=<proj>" 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;
}