fix(scheduler): fix limit-plugin usergroup/scope/error-code cascade (gitea#46)

While live-verifying gitea#46, found and fixed a cascade of 5 previously
undetected bugs that together made every usergroup-scoped limit in
limits.yaml silently non-functional:

- Named usergroups/hostgroups (users.yaml/hosts.yaml) were never
  registered into the scheduler's group managers (groupmgr_add() had zero
  production callers), so any limit referencing one by name could never
  resolve real membership.
- An unconfigured limit scope (e.g. no "projects:") arrived as an
  empty-but-present capnp group, not NULL, so build_consumer() built a
  real empty group (matching no one) instead of falling back to "all".
- Three separate call sites conflated key_find()'s negative "not found"
  return with a real error, short-circuiting before ever reaching the
  "instantiate on first use" logic right below them -- the same defect
  class already fixed once before in this codebase (bug-1495).
- Two call sites passed &(sched->core.resmgr) where a plain handle was
  expected, one level of indirection too many, causing RTTI's key_find()
  to reject it as an invalid handle.

All five are live-verified: fresh cluster deploys now correctly match and
instantiate usage tracking for all 7 limits in limits.yaml on every job
submission, reproduced across multiple independent from-scratch redeploys.

A 6th, architecturally distinct issue remains open (bug-1789, logged, not
fixed): sched_fill_limit_requests() computes a job's consumption of a
slots-scoped global limit by reading the wrong request vector, so the
numeric cap itself still never binds. This needs a design decision on
intended cross-scope semantics, not a mechanical fix, so it's left for a
follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb6Edtj4KbS8CrgEWYdA2E
This commit is contained in:
Rongsong Shen 2026-09-07 12:44:16 +00:00
parent 099dbdabdc
commit 22cfe61642
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
8 changed files with 432 additions and 19 deletions

View file

@ -255,6 +255,110 @@ static void test_nest2(void)
destroy(mgr);
}
/* bug-1784: xce_conf4sched_t's n_ugroups/ugroups (and n_hgroups/hgroups)
* carry the cluster's NAMED usergroups/hostgroups (e.g. users.yaml's
* top-level "usergroups: [{groupname: ug1, members: [user1, user2]}]")
* down to the scheduler, but nothing ever registered them into a group
* manager by name -- groupmgr_add() (the obvious candidate) has zero
* production callers. Left unregistered, any OTHER group config that
* references "ug1" as a member (e.g. a limit's own per-limit consumer
* group, built via build_consumer()/makeGroup() in plugins/limit.c) can
* never resolve it: make_g_member() calls get_group(mgr, "ug1"), finds
* nothing, and falls back to treating "ug1" as a literal, ungrouped member
* name that will never strcmp-match a real username -- so a limit scoped
* to a named usergroup silently never matches anyone. Live-confirmed via
* gitea#46 investigation: a real ug1 member ran 10 jobs against a
* configured cap of 8 with zero enforcement.
*
* groupmgr_load_configs() is the fix: a bulk loader for exactly the
* config-push shape (a count + xce_group_config_t** array) that registers
* each entry by name via the existing, already-correct makeGroup(), so
* later group configs (limits, or another named group) can resolve it via
* get_group() by name, the same way test_nest() above proves manual
* back-to-back makeGroup() calls already work.
*/
static void test_load_configs_registers_each_group_by_name(void)
{
var mgr;
xce_group_config_t g1 = {
.name = "ug1",
.members = { .count = 2, .data = (char*[]) { "user1", "user2" } }
};
xce_group_config_t g2 = {
.name = "ug2",
.members = { .count = 1, .data = (char*[]) { "user3" } }
};
xce_group_config_t* configs[2] = { &g1, &g2 };
int rc;
groupmgr_init(&mgr, 16, 16);
rc = groupmgr_load_configs(mgr, 2, configs);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(get_group(mgr, "ug1") != NULL);
TEST_ASSERT_TRUE(get_group(mgr, "ug2") != NULL);
TEST_ASSERT_TRUE(is_in_group(get_group(mgr, "ug1"), "user1") > 0);
TEST_ASSERT_TRUE(is_in_group(get_group(mgr, "ug1"), "user2") > 0);
TEST_ASSERT_TRUE(is_in_group(get_group(mgr, "ug1"), "user3") == 0);
destroy(mgr);
}
/* THE regression, reproduced directly at the group layer: a limit-style
* consumer group (empty name -- build_consumer() always leaves per-limit
* group configs unnamed, deriving a default name instead, config-design.md
* "combined scope" convention) that references a NAMED group ("ug1") as a
* member must resolve real membership through it, exactly as a real
* xce_limit_t's CONSUMER_USER group would when it references
* users.yaml's "ug1". Before the fix (no bulk loader called, "ug1" never
* registered), this fails: is_in_group() falls through to a literal
* strcmp("ug1", "user1") that never matches.
*/
static void test_load_configs_enables_group_name_as_member_reference(void)
{
var mgr;
xce_group_config_t ug1 = {
.name = "ug1",
.members = { .count = 2, .data = (char*[]) { "user1", "user2" } }
};
xce_group_config_t* configs[1] = { &ug1 };
xce_group_config_t limit_group_cfg = {
.members = { .count = 1, .data = (char*[]) { "ug1" } }
};
xce_group_t* limit_group;
int rc;
groupmgr_init(&mgr, 16, 16);
rc = groupmgr_load_configs(mgr, 1, configs);
TEST_ASSERT_EQUAL_INT(0, rc);
/* mirrors build_consumer()'s own call shape: no config->name, a
* caller-supplied default_g_name instead. */
limit_group = makeGroup(mgr, &limit_group_cfg, "some_limit-ug-group");
TEST_ASSERT_TRUE(limit_group != NULL);
TEST_ASSERT_TRUE(is_in_group(limit_group, "user1") > 0);
TEST_ASSERT_TRUE(is_in_group(limit_group, "user2") > 0);
TEST_ASSERT_TRUE(is_in_group(limit_group, "user3") == 0);
destroy(mgr);
}
static void test_load_configs_zero_count_is_a_noop(void)
{
var mgr;
int rc;
groupmgr_init(&mgr, 16, 16);
rc = groupmgr_load_configs(mgr, 0, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
destroy(mgr);
}
static void test_reconfigure_same_group_members(void)
{
var mgr;
@ -311,6 +415,9 @@ void test_group(void)
RUN_TEST(test_nest);
RUN_TEST(test_nest2);
RUN_TEST(test_reconfigure_same_group_members);
RUN_TEST(test_load_configs_registers_each_group_by_name);
RUN_TEST(test_load_configs_enables_group_name_as_member_reference);
RUN_TEST(test_load_configs_zero_count_is_a_noop);
xstring_table_fini();
}

View file

@ -65,6 +65,37 @@ static void test_build_consumer2(void)
return;
}
/* bug-1785: capnp encodes LimitConfig's users/queues/projects/hosts as
* embedded GROUPS, not nullable pointers -- so a scope the YAML author
* never configured (e.g. no "projects:" block) still arrives here as a
* non-NULL xce_group_config_t with zero members/ex_members, not NULL.
* build_consumer() must treat that the same as a genuinely NULL g_config
* (fall back to the "all" group, matching everyone) rather than building
* a real, empty, unnamed group that matches no one -- the opposite of
* this function's own documented default, and the reason a limit scoped
* to only some of the four dimensions (e.g. users+queues but not
* projects+hosts, matching this repo's own limits.yaml) never matched
* anyone at all: its always-present, always-empty unconfigured scopes
* rejected every job.
*/
static void test_build_consumer_empty_config_defaults_to_all(void)
{
xce_consumer_t* c = NULL;
var mgr;
xce_group_config_t gdata = { 0 };
int rc = 0;
groupmgr_init(&mgr, 4, 4);
rc = build_consumer(&c, mgr, CONSUMER_PROJECT, 0, &gdata, "some_limit");
TEST_ASSERT_TRUE(rc == 1);
TEST_ASSERT_NOT_NULL(c->group);
TEST_ASSERT_TRUE(is_in_group(c->group, "any_project_name") > 0);
kill_consumer(c);
destroy(mgr);
}
static void test_build_limit_ok1(void)
{
var umgr;
@ -337,6 +368,7 @@ void test_limit(void)
RUN_TEST(test_build_consumer1);
RUN_TEST(test_build_consumer2);
RUN_TEST(test_build_consumer_empty_config_defaults_to_all);
RUN_TEST(test_build_limit_ok1);
RUN_TEST(test_match_limit_ok1);
RUN_TEST(test_match_limit_ok2);