limit audit: correct PEND reason semantics + percent-map + project normalization

Limit-subsystem audit fixes (#1, #2, #4b, #5, #7; #3/#6/#8 deferred):

#1 PEND reason semantics inversion (the real fix):
- plugins/limit.c: remove the bogus rc<=0 PREASON_LIMIT_GLOBAL/HOST attach
  blocks from match_job_global_limit_()/match_job_host_limit_(). match_limit()
  only knows scope membership (1 = in scope, 0 = not), never usage-vs-cap --
  the attach fired "quota exhausted" for every job OUTSIDE the limit's scope
  and never for a real exhaustion.
- resource.c sched_try_alloc_on_host_() + alloc_runner.c
  sched_alloc_prepare_shared_(): attach the real verdict in the generic
  satisfy check's unmet loops -- an unmet resource with XCE_RESF_LIMIT now
  reports PREASON_LIMIT_HOST (per-host loop, host named) / PREASON_LIMIT_GLOBAL
  (shared loop, host-less) instead of the generic NO_RESOURCE; the vres name
  ("limit=<name>[/u=..][/q=..][/p=..]") rides in the reason's resource field,
  reverse-parseable to the bare limit name.

#2: rtr_reason_name() now covers codes 4-9 (aligned with pendReasonName() in
the apiserver and reasonScenario() in the MCP server); preason.h's
main-reason comment states the real producer contract.

#4b: make_sched_obj() normalizes obj->project ""/NULL -> "default", matching
sched_get_project()'s default -- a limit scoped to projects.members:[default]
now matches obj->project the same as job->project (the wire decodes an absent
project as "", not NULL).

#5: percent_capacity_resource_map: "swap" -> "swp" (the live load index name
workers register; "swap" matched no live resource, so a percent limit on it
fell back to the live-value base bug-1834 exists to avoid) + add tmp ->
maxtmp. Fixture and lib tests follow the live names.

#7 (coverage): test_alloc_runner.c asserts an unmet shared limit vres gets
PREASON_LIMIT_GLOBAL; test_alloc_runner_search.c asserts an unmet per-host
limit vres gets PREASON_LIMIT_HOST with the host named;
test_repl_trace_e2e.c / test_trace_render.c and the repl_trace_baseline
fixtures (XCE_REPL_BASELINE_REGEN=1) follow the corrected reason rendering.

Full tree: 134/134 tests green (scheduler 100, libxce, harness, controller,
worker, autoscaler, apiserver).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-22 13:11:01 +00:00
parent 993daa8f95
commit c194973d42
17 changed files with 295 additions and 64 deletions

View file

@ -8,8 +8,12 @@
#define PREASON_NO_RESOURCE (2)
#define PREASON_MATCH_FAIL (3)
/* LSF-style PEND reason classes beyond plain capacity / host-match failure.
* Codes 4+ feed both main_reason and the host/res detail rows; the API layer
* maps each to a human-readable name (pendReasonName in apiserver). */
* add_job_preason() only ever writes main_reason when called with BOTH host
* and res NULL (which erases the detail lists); every current scheduler
* producer of codes 4+ attaches them as host/res DETAIL rows -- the API
* layer maps each code to a human-readable name (pendReasonName in
* apiserver) wherever it appears, and promotes the first detail row to the
* API-visible main_reason_name (pend-reason P0). */
#define PREASON_LIMIT_GLOBAL (4) /* global quota hit (ug1_slots, ug2_slots_per_user, ...) */
#define PREASON_LIMIT_HOST (5) /* host-based quota hit (hg1_slots_per_host) */
#define PREASON_QUEUE_CLOSED (6) /* queue closed/disabled, not accepting jobs */

View file

@ -731,7 +731,7 @@ test_instantiate_limit_percent_mem_limit_resolves_percent_res_to_maxmem(
}
static void
test_instantiate_limit_percent_swap_limit_resolves_percent_res_to_maxswap(
test_instantiate_limit_percent_swp_limit_resolves_percent_res_to_maxswap(
void)
{
var umgr;
@ -743,9 +743,9 @@ test_instantiate_limit_percent_swap_limit_resolves_percent_res_to_maxswap(
xce_vres_t* vres = NULL;
int rc;
xce_limit_config_t limit_config
= { .name = "pct_swap_limit",
= { .name = "pct_swp_limit",
.val
= &(xce_limit_val_t) { .resname = "swap", .percent = 1,
= &(xce_limit_val_t) { .resname = "swp", .percent = 1,
.val = 0.5 } };
groupmgr_init(&umgr, 4, 4);
@ -761,10 +761,15 @@ test_instantiate_limit_percent_swap_limit_resolves_percent_res_to_maxswap(
rc = instantiate_limit(&vres, resmgr, limit, "", "", "");
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_NULL(vres);
/* "swp", not "swap": workers register the swap load index as "swp"
* (resource_sync.c init_builtin_load_indices()); the percent map must
* key the LIVE name or a percent limit on it falls back to the
* live-value base bug-1834 exists to avoid. */
TEST_ASSERT_NOT_NULL_MESSAGE(
vres->percent_res,
"bug-1834: a percent-based 'swap' limit must resolve a static "
"capacity-source resource ('maxswap'), same pairing as mem/maxmem");
"bug-1834 follow-up: a percent-based 'swp' limit must resolve a "
"static capacity-source resource ('maxswap'), same pairing as "
"mem/maxmem");
TEST_ASSERT_EQUAL_STRING("maxswap", vres->percent_res->name);
TEST_ASSERT_EQUAL_INT(TEST_RES_MAXSWAP_IDX, vres->percent_res->idx);
@ -776,10 +781,63 @@ test_instantiate_limit_percent_swap_limit_resolves_percent_res_to_maxswap(
destroy(qmgr);
destroy(umgr);
printf("test_instantiate_limit_percent_swap_limit_resolves_percent_res_"
printf("test_instantiate_limit_percent_swp_limit_resolves_percent_res_"
"to_maxswap: PASS\n");
}
static void
test_instantiate_limit_percent_tmp_limit_resolves_percent_res_to_maxtmp(
void)
{
var umgr;
var qmgr;
var hmgr;
var pmgr;
var resmgr;
xce_limit_t* limit;
xce_vres_t* vres = NULL;
int rc;
xce_limit_config_t limit_config
= { .name = "pct_tmp_limit",
.val
= &(xce_limit_val_t) { .resname = "tmp", .percent = 1,
.val = 0.5 } };
groupmgr_init(&umgr, 4, 4);
groupmgr_init(&qmgr, 4, 4);
groupmgr_init(&hmgr, 4, 4);
groupmgr_init(&pmgr, 4, 4);
resmgr = make_test_resmgr();
TEST_ASSERT_NOT_NULL(resmgr);
rc = build_limit(&limit, umgr, qmgr, hmgr, pmgr, &limit_config);
TEST_ASSERT_TRUE(rc == 1);
rc = instantiate_limit(&vres, resmgr, limit, "", "", "");
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_NOT_NULL(vres);
/* tmp/maxtmp: the table's own documented future one-liner, now real --
* same dynamic/static pairing as mem/maxmem. */
TEST_ASSERT_NOT_NULL_MESSAGE(
vres->percent_res,
"bug-1834 follow-up: a percent-based 'tmp' limit must resolve a "
"static capacity-source resource ('maxtmp'), same pairing as "
"mem/maxmem");
TEST_ASSERT_EQUAL_STRING("maxtmp", vres->percent_res->name);
TEST_ASSERT_EQUAL_INT(TEST_RES_MAXTMP_IDX, vres->percent_res->idx);
free_instantiated_vres(resmgr, vres);
kill_limit(limit);
free_test_resmgr(resmgr);
destroy(pmgr);
destroy(hmgr);
destroy(qmgr);
destroy(umgr);
printf("test_instantiate_limit_percent_tmp_limit_resolves_percent_res_"
"to_maxtmp: PASS\n");
}
static void
test_instantiate_limit_absolute_value_mem_limit_leaves_percent_res_null(
void)
@ -903,7 +961,9 @@ void test_limit(void)
RUN_TEST(
test_instantiate_limit_percent_mem_limit_resolves_percent_res_to_maxmem);
RUN_TEST(
test_instantiate_limit_percent_swap_limit_resolves_percent_res_to_maxswap);
test_instantiate_limit_percent_swp_limit_resolves_percent_res_to_maxswap);
RUN_TEST(
test_instantiate_limit_percent_tmp_limit_resolves_percent_res_to_maxtmp);
RUN_TEST(
test_instantiate_limit_absolute_value_mem_limit_leaves_percent_res_null);
RUN_TEST(

View file

@ -35,8 +35,10 @@ var make_test_resmgr(void)
add_resource(resmgr, "ncpus", TEST_RES_NCPUS_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "arch", TEST_RES_ARCH_IDX, XCE_VAL_STRING);
add_resource(resmgr, "maxmem", TEST_RES_MAXMEM_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "swap", TEST_RES_SWAP_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "swp", TEST_RES_SWAP_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "maxswap", TEST_RES_MAXSWAP_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "tmp", TEST_RES_TMP_IDX, XCE_VAL_NUMERIC);
add_resource(resmgr, "maxtmp", TEST_RES_MAXTMP_IDX, XCE_VAL_NUMERIC);
return resmgr;
}
@ -47,8 +49,9 @@ void free_test_resmgr(var resmgr)
/* Free each xce_resource_t we allocated (datakiller=NULL means kvtab
* won't free them, so we must do it here). */
const char* names[] = { "type", "mem", "gpu", "ncpus",
"arch", "maxmem", "swap", "maxswap", NULL };
const char* names[]
= { "type", "mem", "gpu", "ncpus", "arch", "maxmem",
"swp", "maxswap", "tmp", "maxtmp", NULL };
for (int i = 0; names[i] != NULL; i++) {
xce_resource_t* r = NULL;
int rc = key_find(resmgr, (void*)names[i], (void**)&r);

View file

@ -4,16 +4,18 @@
#include <rtti.h>
/* Test resource indices — must match make_test_resmgr() registration order */
#define TEST_RES_TYPE_IDX 0 /* "type" — RES_TYPE_STRING */
#define TEST_RES_MEM_IDX 1 /* "mem" — RES_TYPE_NUMERIC */
#define TEST_RES_GPU_IDX 2 /* "gpu" — RES_TYPE_BOOL */
#define TEST_RES_NCPUS_IDX 3 /* "ncpus" — RES_TYPE_NUMERIC */
#define TEST_RES_ARCH_IDX 4 /* "arch" — RES_TYPE_STRING */
#define TEST_RES_MAXMEM_IDX 5 /* "maxmem" — RES_TYPE_NUMERIC (bug-1834) */
#define TEST_RES_SWAP_IDX 6 /* "swap" — RES_TYPE_NUMERIC (bug-1834) */
#define TEST_RES_MAXSWAP_IDX 7 /* "maxswap" — RES_TYPE_NUMERIC (bug-1834) */
#define TEST_RES_TYPE_IDX 0 /* "type" — RES_TYPE_STRING */
#define TEST_RES_MEM_IDX 1 /* "mem" — RES_TYPE_NUMERIC */
#define TEST_RES_GPU_IDX 2 /* "gpu" — RES_TYPE_BOOL */
#define TEST_RES_NCPUS_IDX 3 /* "ncpus" — RES_TYPE_NUMERIC */
#define TEST_RES_ARCH_IDX 4 /* "arch" — RES_TYPE_STRING */
#define TEST_RES_MAXMEM_IDX 5 /* "maxmem" — RES_TYPE_NUMERIC (bug-1834) */
#define TEST_RES_SWAP_IDX 6 /* "swp" — RES_TYPE_NUMERIC (live swap load-index name, bug-1834 follow-up) */
#define TEST_RES_MAXSWAP_IDX 7 /* "maxswap" — RES_TYPE_NUMERIC (bug-1834) */
#define TEST_RES_TMP_IDX 8 /* "tmp" — RES_TYPE_NUMERIC (bug-1834 follow-up) */
#define TEST_RES_MAXTMP_IDX 9 /* "maxtmp" — RES_TYPE_NUMERIC (bug-1834 follow-up) */
/* Create a test resmgr with 8 pre-registered resources.
/* Create a test resmgr with 10 pre-registered resources.
* Returns a var (kvtab) that can be passed to parse_select / parse_resreq_primary.
* Caller must call free_test_resmgr() when done. */
extern var make_test_resmgr(void);

View file

@ -477,19 +477,28 @@ int is_host_based_limit(var resmgr, xce_limit_t* limit)
}
/* bug-1834: a percent-based limit's capacity must come from a resource's
* static total, not its live/fluctuating value -- "mem"/"swap" are both
* static total, not its live/fluctuating value -- "mem"/"swp"/"tmp" are all
* XCE_RESF_DYNAMIC load indices (resource-accounting.md), so their own
* live value is the wrong base for a stable percentage-of-capacity cap.
* "maxmem"/"maxswap" are the static, self-reported-once counterparts
* (resources.yaml) that should be read instead. Table-driven so a future
* dynamic/static pair (e.g. tmp/maxtmp) is a one-line addition, not a new
* branch. */
* "maxmem"/"maxswap"/"maxtmp" are the static, self-reported-once
* counterparts (resources.yaml; registered per-worker by
* controller/worker.c add_hostattrs_resvals()) that should be read instead.
* Table-driven: a new dynamic/static pair is a one-line addition.
*
* Key names are the LIVE resource names, not display aliases: workers
* register the swap load index as "swp" (resource_sync.c
* init_builtin_load_indices()), never "swap" -- the "swap" key this table
* previously carried matched no live resource, so a percent limit on it
* fell back to the live-value base bug-1834 exists to avoid (a percent
* limit on a "swap" name still can't match a live resource today, but the
* table now names the pair that actually exists). */
static const struct {
const char* resname;
const char* percent_resname;
} percent_capacity_resource_map[] = {
{ "mem", "maxmem" },
{ "swap", "maxswap" },
{ "mem", "maxmem" },
{ "swp", "maxswap" },
{ "tmp", "maxtmp" },
};
static const char* percent_capacity_resname(const char* resname)

View file

@ -100,10 +100,20 @@ static int sched_alloc_prepare_shared_(xce_sched_core_t* core,
continue;
}
/* bug-1927: a COMPOUND's later primary short on the same
* pool does not repeat the entry */
/* A limit's virtual resource (XCE_RESF_LIMIT, added by
* instantiate_limit()) surfaces here exactly when the GLOBAL
* limit's cap is exhausted cluster-wide -- the real quota
* verdict, replacing the removed plugins/limit.c rc<=0 attach
* (see match_job_global_limit_()'s comment there). r->name is
* the vres name "limit=<name>[/u=..][/q=..][/p=..]",
* reverse-parseable to the bare limit name (split_vres_name()
* convention). Host-less row: a global limit is not a
* per-host fact. */
add_job_preason_once(ctx->reason->reasons, &(ctx->reason_index),
PREASON_NO_RESOURCE, NULL, r->name);
(r->flags & XCE_RESF_LIMIT)
? PREASON_LIMIT_GLOBAL
: PREASON_NO_RESOURCE,
NULL, r->name);
}
return 0;

View file

@ -157,17 +157,19 @@ static int match_job_global_limit_(xce_sched_plugin_t* plugin,
TRACE("%s match_limit limit %s user %s queue %s project %s rc %d", __func__,
limit->name, obj->user, obj->queue, obj->project, rc);
if (rc <= 0) {
/* global quota hit — report the specific limit (PEND-reason P1,
* restored from 4e2ac86). The election return value is ignored by
* the main loop, so the reason is carried on ctx->reason and moved
* to obj->reason by limit_job_election. */
if (ctx->reason == NULL) {
ctx->reason = make_pendreason(0);
}
if (ctx->reason != NULL) {
add_job_preason(ctx->reason->reasons, PREASON_LIMIT_GLOBAL, NULL,
limit->name);
}
/* rc == 0: the job is NOT in this limit's scope (match_limit()'s
* "no consumer rejects it" verdict) -- nothing to enforce, nothing
* to report. rc < 0 (-EINVAL): a wiring error, surfaced by the
* caller's rc<0 break.
*
* This is deliberately NOT a PREASON_LIMIT_GLOBAL site, despite
* 4e2ac86 P0's original comment: match_limit() knows only scope
* membership, never usage-vs-cap -- the quota verdict happens
* later, in the generic satisfy check, where the limit's vres is
* an unmet entry like any resource (the real attach lives in
* resource.c's unmet loops). Attaching "quota exhausted" here
* fired for every job OUTSIDE the limit's scope, and never for an
* actual exhausted quota. */
return rc;
}
@ -285,10 +287,11 @@ static int limit_job_election(void* arg, xce_sched_job_t* job)
iter_destroy(&it);
/* Carry any global-quota reason (PREASON_LIMIT_GLOBAL) attached by
* match_job_global_limit_ onto the job so it survives to the PEND
* decision. The election's rc itself is ignored by the main loop.
* (PEND-reason P1, restored from 4e2ac86.) */
/* match_job_global_limit_() can no longer attach a reason (scope
* membership is not a quota verdict -- see its rc<=0 comment), so
* nothing ever reaches here and the election's rc is ignored by the
* main loop as before. Kept as a cheap NULL check to preserve the
* hook's historical "carry anything the election built" shape. */
if (ctx.reason != NULL) {
MOVE_PTR(obj->reason, ctx.reason);
}
@ -380,16 +383,12 @@ static int match_job_host_limit_(xce_sched_plugin_t* plugin,
TRACE("%s match_limit limit %s user %s queue %s project %s rc %d", __func__,
limit->name, obj->user, obj->queue, obj->project, rc);
if (rc <= 0) {
/* host-based quota hit — report the specific limit (PEND-reason P1,
* restored from 4e2ac86; previously the block was silent and the job
* just stayed PEND). */
if (ctx->reason == NULL) {
ctx->reason = make_pendreason(0);
}
if (ctx->reason != NULL) {
add_job_preason(ctx->reason->reasons, PREASON_LIMIT_HOST,
worker->name, limit->name);
}
/* rc == 0: the job (or this host) is NOT in the limit's scope --
* nothing to enforce. See match_job_global_limit_()'s identical
* comment above: the real quota-verdict reason attach lives in
* resource.c's unmet loops, keyed on the limit's vres being an
* unmet entry -- NOT here, where match_limit() only knows scope
* membership. */
return rc;
}

View file

@ -70,6 +70,21 @@ const char* rtr_reason_name(int code, char* buf, size_t bufsz)
return "NO_RESOURCE";
case PREASON_MATCH_FAIL:
return "MATCH_FAIL";
/* codes 4-9: keep the names aligned with pendReasonName() in the
* apiserver (convert_pure.go) and reasonScenario() in the MCP server
* (pendreason.go) -- all three render the same PREASON_* space. */
case PREASON_LIMIT_GLOBAL:
return "LIMIT_GLOBAL";
case PREASON_LIMIT_HOST:
return "LIMIT_HOST";
case PREASON_QUEUE_CLOSED:
return "QUEUE_CLOSED";
case PREASON_QUEUE_DRAINING:
return "QUEUE_DRAINING";
case PREASON_TIME_WINDOW:
return "TIME_WINDOW";
case PREASON_HOST_UNAVAIL:
return "HOST_UNAVAIL";
default:
snprintf(buf, bufsz, "code %d", code);
return buf;

View file

@ -615,11 +615,20 @@ int sched_try_alloc_on_host_(xce_sched_worker_t* w, xce_sched_core_t* core,
continue;
}
/* bug-1927: every plan re-checks this host; record each
* (host, resource, code) once */
/* A limit's virtual resource (XCE_RESF_LIMIT, added by
* instantiate_limit()) surfaces here exactly when its cap
* is exhausted for THIS host -- this is the real quota
* verdict the removed plugins/limit.c rc<=0 attach only
* pretended to make (it fired "quota hit" for jobs OUTSIDE
* a limit's scope). r->name is the vres name
* "limit=<name>[/u=..][/q=..][/p=..]", reverse-parseable to
* the bare limit name (split_vres_name() convention). */
add_job_preason_once(ctx->reason->reasons,
&(ctx->reason_index),
PREASON_NO_RESOURCE, w->name, r->name);
(r->flags & XCE_RESF_LIMIT)
? PREASON_LIMIT_HOST
: PREASON_NO_RESOURCE,
w->name, r->name);
}
return 0;
}

View file

@ -119,7 +119,19 @@ static int make_sched_obj(xce_sched_obj_t* obj, xce_scheduler_t* sched,
obj->user = makeString(j->submit->user);
obj->queue = makeString(j->submit->queue);
obj->project = makeString(j->submit->project_name);
/* project: the live submit wire never carries NULL (an absent project
* decodes as "", STRING_DUP("") in the generated decode), so normalize
* ""/NULL to "default" here -- the SAME default sched_get_project()
* applies when building job->project (core.c). The two views of a
* job's project (obj->project, what limit matching sees;
* job->project->name, what everything else sees) must agree, or a
* limit scoped to projects.members: [default] would match
* job->project but never obj->project. */
if (NULLSTR(j->submit->project_name)) {
obj->project = makeString("default");
} else {
obj->project = makeString(j->submit->project_name);
}
xlist_init(&(obj->glimits), 8);
zarray_init(&(obj->hlimits), 32, kill_hlimits_list);

View file

@ -527,6 +527,56 @@ static void test_shared_met_counted_and_kept_when_no_plan(void)
rt_fixture_fini(&f);
}
/* An unmet entry whose resource is a limit's virtual resource
* (XCE_RESF_LIMIT) reports the quota verdict PREASON_LIMIT_GLOBAL, not the
* generic NO_RESOURCE the plain shared shortfalls get (limit audit #1: the
* vres' unmet state IS the quota exhaustion). */
static void test_shared_short_limit_vres_reasons_limit_global(void)
{
rt_fixture_t f;
xce_resource_t vres_in;
xce_resource_t* vres;
xce_res_reason_t* rr;
rt_fixture_init(&f);
memset(&vres_in, 0, sizeof(vres_in));
vres_in.name = (char*)"limit=fake_limit";
vres_in.flags = XCE_RESF_LIMIT;
vres = makeResource(f.sched.core.resmgr, &vres_in);
TEST_ASSERT_NOT_NULL(vres);
{
int idx[1] = { vres->idx };
float val[1] = { 2.0f };
TEST_ASSERT_TRUE(put_resvals(&(f.sched.core.sharedInst), 1, idx, val)
>= 0);
}
/* lic_a met (3 of 10); the limit vres short (3 of 2). Staged after the
* rerun: sched_ctx_set_primary() -> plugin_prepare_allocation()
* resets alloc_req.shared. */
rt_want_shared(&f, 3.0f, 0.0f);
TEST_ASSERT_TRUE(
xvec_idval_store(&(f.ctx.alloc_req.shared), vres->idx, 3.0f) > 0);
TEST_ASSERT_EQUAL_INT(0, rt_run(&f));
TEST_ASSERT_NULL(f.ctx.alloc);
TEST_ASSERT_NOT_NULL(f.ctx.reason);
/* Only the UNMET entry gets a row: lic_a (3 of 10) is met and stays
* silent; the vres (3 of 2) is the one shortfall. */
TEST_ASSERT_EQUAL_INT(1, f.ctx.reason->reasons->nres_reasons);
rr = f.ctx.reason->reasons->res_based_reasons[0];
TEST_ASSERT_NOT_NULL(rr);
TEST_ASSERT_EQUAL_STRING("", rr->host);
TEST_ASSERT_EQUAL_STRING("limit=fake_limit", rr->resource);
TEST_ASSERT_EQUAL_INT_MESSAGE(PREASON_LIMIT_GLOBAL, rr->reason,
"an exhausted limit's vres must report "
"LIMIT_GLOBAL, not NO_RESOURCE");
rt_fixture_fini(&f);
}
/* The helpers directly: a shared request of nothing is met and counts
* nothing; the reason helper keeps an existing record. */
static void test_helpers_empty_shared_is_met(void)
@ -554,6 +604,7 @@ int main(void)
RUN_TEST(test_existing_reason_is_reused);
RUN_TEST(test_reason_oom_returns_enomem_without_checks);
RUN_TEST(test_shared_short_one_reason_per_unmet_entry);
RUN_TEST(test_shared_short_limit_vres_reasons_limit_global);
RUN_TEST(test_shared_met_counted_and_kept_when_plan_init_fails);
RUN_TEST(test_shared_met_counted_and_kept_when_no_plan);
RUN_TEST(test_helpers_empty_shared_is_met);

View file

@ -138,6 +138,9 @@ static int g_nlog = 0;
static int g_slots_idx = -1;
static int g_lic_idx = -1;
static float g_shared_want = 0.0f; /* lic per job; 0 = none */
/* per-host limit vres staged by the limit plugin when set (test-only) */
static int g_host_vres_idx = -1;
static float g_host_vres_want = 0.0f;
int plugin_prepare_allocation(xce_sched_core_t* sched, xce_sched_ctx_t* ctx)
{
@ -167,6 +170,11 @@ int plugin_prepare_allocation(xce_sched_core_t* sched, xce_sched_ctx_t* ctx)
TEST_ASSERT_TRUE(xvec_idval_store(&(ctx->alloc_req.host), g_slots_idx,
(float)ctx->nslots)
> 0);
if (g_host_vres_idx >= 0) {
TEST_ASSERT_TRUE(xvec_idval_store(&(ctx->alloc_req.host),
g_host_vres_idx, g_host_vres_want)
> 0);
}
return 0;
}
@ -243,6 +251,8 @@ void setUp(void)
g_dup_fail_on = 0;
g_nlog = 0;
g_shared_want = 0.0f;
g_host_vres_idx = -1;
g_host_vres_want = 0.0f;
g_nplans = 0;
g_init_result = 0;
g_init_calls = 0;
@ -635,6 +645,49 @@ static void test_short_host_reason_recorded_once_across_plans(void)
sr_fixture_fini(&f);
}
/* An unmet entry whose resource is a host-scoped limit's virtual resource
* (XCE_RESF_LIMIT) reports the quota verdict PREASON_LIMIT_HOST with the
* host named, not the generic NO_RESOURCE (limit audit #1: the vres' unmet
* state IS the quota exhaustion). */
static void test_short_host_limit_vres_reasons_limit_host(void)
{
sr_fixture_t f;
xce_sched_worker_t* a;
xce_resource_t* vres;
xce_res_reason_t* rr;
sr_fixture_init(&f);
a = sr_add_host(&f, "sr-a", 4.0f);
vres = makeResource(
f.sched.core.resmgr,
&(xce_resource_t) { .name = (char*)"limit=fake_host_limit/u=hdedup_user",
.flags = XCE_RESF_LIMIT });
TEST_ASSERT_NOT_NULL(vres);
{
int idx[1] = { vres->idx };
float val[1] = { 3.0f }; /* capacity 3 on this host */
TEST_ASSERT_TRUE(put_resvals(&(a->resvals), 1, idx, val) >= 0);
}
g_host_vres_idx = vres->idx;
g_host_vres_want = 4.0f; /* 4 requested against 3: short */
sr_ctx_begin(&f, 2);
script_plan(1, 2, 0, 0);
TEST_ASSERT_EQUAL_INT(0, sr_run(&f));
TEST_ASSERT_NOT_NULL(f.ctx.reason);
TEST_ASSERT_EQUAL_INT(1, f.ctx.reason->reasons->nres_reasons);
rr = f.ctx.reason->reasons->res_based_reasons[0];
TEST_ASSERT_NOT_NULL(rr);
TEST_ASSERT_EQUAL_STRING("sr-a", rr->host);
TEST_ASSERT_EQUAL_STRING("limit=fake_host_limit/u=hdedup_user",
rr->resource);
TEST_ASSERT_EQUAL_INT_MESSAGE(PREASON_LIMIT_HOST, rr->reason,
"an exhausted host limit's vres must report "
"LIMIT_HOST, not NO_RESOURCE");
sr_fixture_fini(&f);
}
/* ---------------------------------------------------------------------- */
/* Malformed plans and the claims buffer (Req 3.8, 5.7) */
/* ---------------------------------------------------------------------- */
@ -795,6 +848,7 @@ int main(void)
RUN_TEST(test_rollback_then_later_plan_with_bucket_counts);
RUN_TEST(test_every_short_host_gets_reason);
RUN_TEST(test_short_host_reason_recorded_once_across_plans);
RUN_TEST(test_short_host_limit_vres_reasons_limit_host);
RUN_TEST(test_malformed_plans_skipped);
RUN_TEST(test_claims_buffer_grows_to_larger_plan);
RUN_TEST(test_claims_buffer_growth_failure);

View file

@ -354,7 +354,10 @@ static void test_step_mode_full_all_jobs_and_file_copy(void)
"\n outcome: PEND\n",
"\n main reason: none\n",
"\n resource license: NO_RESOURCE\n",
"\n resource limit=cluster_slots: NO_RESOURCE\n");
/* The limit's vres being unmet carries the real quota verdict
* (PREASON_LIMIT_GLOBAL), not the generic NO_RESOURCE it used
* to share with the license shortfall. */
"\n resource limit=cluster_slots: LIMIT_GLOBAL\n");
TEST_ASSERT_NULL(strstr(b, "plan 1"));
free(b);
/* 4.2: the cluster-wide limit is never shown as a resource row. */
@ -368,11 +371,11 @@ static void test_step_mode_full_all_jobs_and_file_copy(void)
"\n requirement alternative 1/2 > member 1/1 tasks 4..4 "
"requests mem=12288.000000 (12.00 KB)\n",
"\n member 1/1 not satisfied\n",
"\n failure reasons: NO_RESOURCE x1 (resources: "
"\n failure reasons: LIMIT_GLOBAL x1 (resources: "
"limit=cluster_slots)\n",
"\n alternative 1/2 not satisfied\n",
"\n requirement alternative 2/2 (compound, 1 member)\n",
"\n failure reasons: NO_RESOURCE x1 (resources: "
"\n failure reasons: LIMIT_GLOBAL x1 (resources: "
"limit=cluster_slots)\n",
"\n alternative 2/2 not satisfied\n",
"\n no alternative chosen: none of the 2 alternatives was "

View file

@ -815,7 +815,7 @@ static void test_pend_outcome(void)
" resource license: NO_RESOURCE\n"
" host n01: MATCH_FAIL, resource mem: NO_RESOURCE\n"
" host n02: MATCH_FAIL\n"
" omitted hosts' pend reasons: MATCH_FAIL x2, code 9 x1\n"
" omitted hosts' pend reasons: MATCH_FAIL x2, HOST_UNAVAIL x1\n"
" ... 2 hosts omitted\n",
out);
free(out);