myxce/src/scheduler/testsuite/test_scheduler_dispatch_commit.c
Rongsong Shen 01c8306694
test(scheduler-dispatch-commit): bug-1624 multi-round regression coverage (task 5.1)
Extends task 2.1's test_scheduler_dispatch_commit.c with two tests
proving resource-in-use accounting survives more than a single round
boundary between dispatch and worker confirmation, per task 5.1's
"several rounds" / "matching a same-round confirmation" language:

- test_dispatch_commit_survives_several_rounds_before_confirmation:
  loops 5 round-boundary cycles, asserting resInUse after EVERY round,
  not just once.
- test_dispatch_commit_multi_round_gap_matches_same_round_confirmation:
  compares resInUse after 1 round vs after 5 rounds and asserts
  equality -- a same-round confirmation is modeled as nrounds==1 since
  design.md documents re-applying JOB_STAT_RUN to an already-RUN job as
  a confirmed no-op, so resInUse depends only on job->state, never on
  confirmation timing.

Test-only; scheduler.c is untouched. RED phase independently verified
by both implementer and reviewer via an actual revert-rebuild-rerun
cycle (4/5 tests fail without the task 2.1 fix). Independently reviewed
and approved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cxa1PmqAB5PyXfJaNvj8TN
2026-08-23 18:40:53 +00:00

448 lines
17 KiB
C

/*
* test_scheduler_dispatch_commit.c - scheduler-dispatch-commit spec, task
* 2.1 (Req 1.1, 1.2, 1.3): sched_commit_decision()'s DISPATCH branch
* transitions a job to JOB_STAT_RUN in the same operation that already
* commits job->allocs/job->accounts, so sched_account_jobs()'s existing
* JOB_IS_RUN() gate (scheduler.c, unmodified by this task) picks up a
* dispatched job's resource consumption starting the same round it was
* dispatched, and continues to include it regardless of how many rounds
* elapse before worker confirmation arrives (bug-1624).
*
* Links the REAL production scheduler_srcs (same "whole-scheduler_srcs,
* minus main.c" technique test_scheduler_connect.c/
* test_scheduler_backpressure.c already establish in meson.build for
* reaching scheduler.c's non-static entry points without white-box
* #including a ~1400-line file -- no existing test in this directory does
* that, for exactly the link-surface reason test_resource_accounting.c's
* own header comment documents). Unlike those two files, this one never
* calls sched_init()/sched_fini() (no controller-join, no picker/allocator
* threads), so it needs none of their `-Wl,--wrap=xcecli_*` interposition:
* sched_commit_decision() itself only touches sched->core and the (empty,
* in this fixture) plugin-hook chain, never sched->apictx.
*
* Extended for task 5.1 (Req 1.1, 1.2, 1.3, 2.1, 2.3 -- bug-1624's
* end-to-end regression test): task 2.1's own single-round-boundary test
* above proves the core mechanism; the two tests added at the bottom of
* this file extend that proof to (a) GAP_ROUNDS=5 consecutive round
* boundaries rather than just one, and (b) an explicit value comparison
* between a "confirmed same round" scenario and a "confirmed after several
* rounds" scenario, rather than only comparing each to a hardcoded
* constant -- see run_dispatch_then_n_rounds()'s own comment for why no
* live controller connection or simulated schedStartJob notify event is
* needed to make that comparison meaningful: the controller's own role in
* this flow (transitioning to RUN, notifying) is already independently
* covered by tasks 3.1-4.1's controller-side suites
* (test_dispatch_commit.c, test_dispatch_fail_terminal.c,
* test_dispatch_fail_retry.c, test_jobdispatch_no_double_notify.c); nothing
* scheduler-side that determines resInUse ever reads a confirmation event
* at all, only job->state, which task 2.1's fix already sets synchronously
* at dispatch-commit.
*/
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "preason.h"
#include "scheduler.h"
#include "xcelimit.h"
#define WORKER_NAME ((char*)"worker1")
#define JOB_ID 4242ULL
#define RES_NAME "slots"
#define WANT_VALUE 4.0f
#define GAP_ROUNDS 5
static xce_sched_worker_t* add_worker(xce_sched_core_t* core, char* name)
{
xce_sched_worker_t* w = (xce_sched_worker_t*)calloc(1, sizeof(*w));
int rc;
TEST_ASSERT_NOT_NULL(w);
w->name = strdup(name);
TEST_ASSERT_NOT_NULL(w->name);
rc = zarray_init(&(w->resvals), 8, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = zarray_init(&(w->resInUse), 8, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = sched_add_worker(core, w);
TEST_ASSERT_TRUE(rc >= 0);
return w;
}
static xce_sched_job_t* add_pending_job(xce_sched_core_t* core, uint64_t jobid)
{
xce_sched_job_t* job = (xce_sched_job_t*)calloc(1, sizeof(*job));
int rc;
TEST_ASSERT_NOT_NULL(job);
job->jobid = jobid;
job->state = JOB_STAT_PEND;
rc = sched_add_job(core, job);
TEST_ASSERT_TRUE(rc >= 0);
return job;
}
/* Builds the wire-shaped xce_job_alloc_t a DISPATCH decision carries -- same
* field shape every other fixture in this directory building one by hand
* uses (e.g. test_sim_resource_contention.c's own make_dispatch_alloc()). */
static xce_job_alloc_t* make_dispatch_alloc(uint64_t jobid, char* host,
float value)
{
xce_alloc_t* res = (xce_alloc_t*)calloc(1, sizeof(*res));
xce_host_alloc_t* h = (xce_host_alloc_t*)calloc(1, sizeof(*h));
xce_host_alloc_t** hosts = (xce_host_alloc_t**)calloc(1, sizeof(*hosts));
xce_alloc_t** allocs = (xce_alloc_t**)calloc(1, sizeof(*allocs));
xce_job_alloc_t* j = (xce_job_alloc_t*)calloc(1, sizeof(*j));
TEST_ASSERT_NOT_NULL(res);
TEST_ASSERT_NOT_NULL(h);
TEST_ASSERT_NOT_NULL(hosts);
TEST_ASSERT_NOT_NULL(allocs);
TEST_ASSERT_NOT_NULL(j);
res->resname = strdup(RES_NAME);
res->reserve = strdup("run");
res->instid = 1;
res->value = value;
res->from = 0;
res->duration = 0;
allocs[0] = res;
h->host = strdup(host);
h->nallocs = 1;
h->allocs = allocs;
hosts[0] = h;
j->jobid = jobid;
j->nhosts = 1;
j->allocs = hosts;
j->nres_shared = 0;
j->allocs_shared = NULL;
return j;
}
void setUp(void) { return; }
void tearDown(void) { return; }
/*
* Requirement 1.1: committing a DISPATCH decision transitions the job to
* JOB_STAT_RUN in the SAME operation that commits job->allocs/job->accounts
* -- the one new line this task adds to sched_commit_decision(), alongside
* the pre-existing allocs/accounts assignment it must not disturb.
*/
static void test_dispatch_commit_transitions_job_to_run(void)
{
xce_scheduler_t sched;
xce_sched_job_t* job;
xce_job_alloc_t* alloc;
xce_decision_t* decision;
int rc;
memset(&sched, 0, sizeof(sched));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
add_worker(&(sched.core), WORKER_NAME);
job = add_pending_job(&(sched.core), JOB_ID);
TEST_ASSERT_EQUAL_UINT(JOB_STAT_PEND, job->state);
alloc = make_dispatch_alloc(JOB_ID, WORKER_NAME, WANT_VALUE);
decision = make_dispatch_decision(alloc);
TEST_ASSERT_NOT_NULL(decision);
rc = sched_commit_decision(&sched, decision);
TEST_ASSERT_TRUE(rc >= 0);
/* the new behavior this task adds */
TEST_ASSERT_EQUAL_UINT(JOB_STAT_RUN, job->state);
/* the pre-existing behavior this task must not disturb */
TEST_ASSERT_EQUAL_PTR(alloc, job->allocs);
TEST_ASSERT_NOT_NULL(job->accounts);
TEST_ASSERT_EQUAL_INT(1, job->accounts->nhosts);
TEST_ASSERT_EQUAL_STRING(WORKER_NAME, job->accounts->accounts[0]->host);
}
/*
* Requirement 1.2 / 1.3 (bug-1624's actual regression test): the
* scheduler's existing sched_account_jobs()/JOB_IS_RUN() gate -- unmodified
* by this task -- naturally includes a dispatched job's resource
* consumption starting the SAME round it was dispatched, and continues to
* include it across a simulated round boundary (resInUse unconditionally
* zeroed, exactly as xce_core_begin_session() does every round) with no
* worker confirmation event in between. Before this task, job->state would
* still read JOB_STAT_PEND at this point, so sched_account_jobs()'s
* !JOB_IS_RUN() branch would skip the job and the final assertion below
* would see 0.0 instead of WANT_VALUE.
*/
static void test_dispatch_commit_survives_round_boundary_before_confirmation(void)
{
xce_scheduler_t sched;
xce_sched_worker_t* w;
xce_sched_job_t* job;
xce_job_alloc_t* alloc;
xce_decision_t* decision;
xce_resource_t* vres = NULL;
int rc;
int residx;
float vals[1];
float zero[1] = { 0.0f };
memset(&sched, 0, sizeof(sched));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
w = add_worker(&(sched.core), WORKER_NAME);
job = add_pending_job(&(sched.core), JOB_ID);
/* Register RES_NAME up front, the way real resource config-load does
* before any job is ever charged, rather than relying on
* sched_update_res_accounts()'s own lazy first-use registration path --
* out of scope for what this test is proving. */
residx = add_vres(&vres, sched.core.resmgr, RES_NAME, 0);
TEST_ASSERT_TRUE(residx >= 0);
alloc = make_dispatch_alloc(JOB_ID, WORKER_NAME, WANT_VALUE);
decision = make_dispatch_decision(alloc);
TEST_ASSERT_NOT_NULL(decision);
rc = sched_commit_decision(&sched, decision);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_EQUAL_UINT(JOB_STAT_RUN, job->state);
TEST_ASSERT_NOT_NULL(job->accounts);
TEST_ASSERT_EQUAL_INT(residx, job->accounts->accounts[0]->accounts[0]->residx);
get_resvals(&(w->resInUse), 1, &residx, vals);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, vals[0]);
/* simulate the next round boundary */
put_resvals(&(w->resInUse), 1, &residx, zero);
get_resvals(&(w->resInUse), 1, &residx, vals);
TEST_ASSERT_EQUAL_FLOAT(0.0f, vals[0]);
rc = sched_account_jobs(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
get_resvals(&(w->resInUse), 1, &residx, vals);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, vals[0]);
}
/*
* Negative-space guard: this task's new line lives strictly inside the
* XCE_DECISION_DISPATCH branch. A PEND decision must not touch job->state
* at all (still JOB_STAT_PEND) -- proves the fix is scoped to DISPATCH, not
* an unconditional job->state = JOB_STAT_RUN hoisted above the branch.
*/
static void test_pend_decision_does_not_touch_job_state(void)
{
xce_scheduler_t sched;
xce_sched_job_t* job;
xce_pendreason_t* reason;
xce_decision_t* decision;
int rc;
memset(&sched, 0, sizeof(sched));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
job = add_pending_job(&(sched.core), JOB_ID);
reason = make_pendreason(JOB_ID);
TEST_ASSERT_NOT_NULL(reason);
decision = make_pend_decision(reason);
TEST_ASSERT_NOT_NULL(decision);
rc = sched_commit_decision(&sched, decision);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_EQUAL_UINT(JOB_STAT_PEND, job->state);
TEST_ASSERT_NULL(job->allocs);
TEST_ASSERT_NULL(job->accounts);
}
/*
* Task 5.1 (bug-1624's own regression test, "several rounds" language):
* extends the single-round-boundary proof above
* (test_dispatch_commit_survives_round_boundary_before_confirmation, task
* 2.1) to GAP_ROUNDS consecutive round boundaries -- zeroing resInUse and
* calling sched_account_jobs() GAP_ROUNDS times in a row, exactly as
* GAP_ROUNDS real driver rounds would via xce_core_begin_session(), with no
* worker-confirmation event injected anywhere in the loop -- and asserts
* resInUse is restored to WANT_VALUE after EVERY one of them, not merely
* that it eventually comes back after one round. Before task 2.1/3.1's fix,
* job->state would still read JOB_STAT_PEND at commit time, so this would
* already fail at round 1 (the same way the pre-existing single-round test
* would); this test additionally proves the fix holds continuously across
* a realistic multi-round confirmation gap, not just a single round
* boundary.
*/
static void test_dispatch_commit_survives_several_rounds_before_confirmation(void)
{
xce_scheduler_t sched;
xce_sched_worker_t* w;
xce_sched_job_t* job;
xce_job_alloc_t* alloc;
xce_decision_t* decision;
xce_resource_t* vres = NULL;
int rc;
int residx;
float val;
float zero[1] = { 0.0f };
int round;
memset(&sched, 0, sizeof(sched));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
w = add_worker(&(sched.core), WORKER_NAME);
job = add_pending_job(&(sched.core), JOB_ID);
residx = add_vres(&vres, sched.core.resmgr, RES_NAME, 0);
TEST_ASSERT_TRUE(residx >= 0);
alloc = make_dispatch_alloc(JOB_ID, WORKER_NAME, WANT_VALUE);
decision = make_dispatch_decision(alloc);
TEST_ASSERT_NOT_NULL(decision);
rc = sched_commit_decision(&sched, decision);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_EQUAL_UINT(JOB_STAT_RUN, job->state);
get_resvals(&(w->resInUse), 1, &residx, &val);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, val);
for (round = 0; round < GAP_ROUNDS; round++) {
/* still no schedStartJob/confirmation event anywhere in this loop
* -- job->accounts/job->state were set once, at dispatch commit,
* and nothing else re-arms them between rounds. */
put_resvals(&(w->resInUse), 1, &residx, zero);
get_resvals(&(w->resInUse), 1, &residx, &val);
TEST_ASSERT_EQUAL_FLOAT(0.0f, val);
rc = sched_account_jobs(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
get_resvals(&(w->resInUse), 1, &residx, &val);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, val);
}
}
/*
* Shared fixture for the comparison test below: builds a fresh core +
* worker + dispatched job (same shape as the tests above), then simulates
* `nrounds` successive round boundaries -- each zeroing resInUse exactly as
* xce_core_begin_session() does every round, then re-deriving it via
* sched_account_jobs() -- with NO worker-confirmation event injected at any
* point, and returns the worker's final resInUse value for RES_NAME.
*
* `nrounds == 1` stands in for "the worker's confirmation would have
* arrived the very same round dispatch was committed": since resInUse for
* a RUN job is fully determined by sched_account_jobs()'s JOB_IS_RUN()
* gate (unmodified by this feature) and job->state is already RUN
* synchronously at dispatch-commit (Requirement 1.1), a same-round
* confirmation event applying JOB_STAT_RUN again is a confirmed no-op
* against an already-RUN job (design.md, "Scheduler Dispatch Commit"
* Responsibilities & Constraints) -- it changes nothing that
* sched_account_jobs() reads. So the very next round's accounting pass
* already reflects exactly what a same-round confirmation would have
* produced; there is nothing further for an explicit confirmation event to
* contribute to this value, which is why simulating that event is not
* needed here to make the comparison meaningful.
*
* `nrounds > 1` simulates however many additional rounds elapse with still
* no confirmation in between. Per Requirement 1.2, the two must be
* identical -- asserted explicitly by
* test_dispatch_commit_multi_round_gap_matches_same_round_confirmation
* below, not merely by separately comparing each against a hardcoded
* constant.
*/
static float run_dispatch_then_n_rounds(int nrounds)
{
xce_scheduler_t sched;
xce_sched_worker_t* w;
xce_sched_job_t* job;
xce_job_alloc_t* alloc;
xce_decision_t* decision;
xce_resource_t* vres = NULL;
int rc;
int residx;
float val;
float zero[1] = { 0.0f };
int i;
memset(&sched, 0, sizeof(sched));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
w = add_worker(&(sched.core), WORKER_NAME);
job = add_pending_job(&(sched.core), JOB_ID);
residx = add_vres(&vres, sched.core.resmgr, RES_NAME, 0);
TEST_ASSERT_TRUE(residx >= 0);
alloc = make_dispatch_alloc(JOB_ID, WORKER_NAME, WANT_VALUE);
decision = make_dispatch_decision(alloc);
TEST_ASSERT_NOT_NULL(decision);
rc = sched_commit_decision(&sched, decision);
TEST_ASSERT_TRUE(rc >= 0);
TEST_ASSERT_EQUAL_UINT(JOB_STAT_RUN, job->state);
for (i = 0; i < nrounds; i++) {
put_resvals(&(w->resInUse), 1, &residx, zero);
rc = sched_account_jobs(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
}
get_resvals(&(w->resInUse), 1, &residx, &val);
return val;
}
/*
* Task 5.1's explicit "matching what a same-round confirmation would have
* produced" language: directly compares the resInUse value produced after
* only ONE round boundary since dispatch (run_dispatch_then_n_rounds(1) --
* see its own comment for why this stands in for "confirmed the same
* round") against the value produced after GAP_ROUNDS round boundaries
* with still no confirmation in between. Per Requirement 1.2, a job's
* contribution to resInUse must not depend on how many rounds elapse
* before confirmation -- both values must be identical, and both must
* equal WANT_VALUE. This is deliberately a value comparison against a
* second, independently-computed scenario, not merely two separate
* comparisons against the same hardcoded constant.
*/
static void test_dispatch_commit_multi_round_gap_matches_same_round_confirmation(void)
{
float same_round_val;
float multi_round_gap_val;
same_round_val = run_dispatch_then_n_rounds(1);
multi_round_gap_val = run_dispatch_then_n_rounds(GAP_ROUNDS);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, same_round_val);
TEST_ASSERT_EQUAL_FLOAT(WANT_VALUE, multi_round_gap_val);
TEST_ASSERT_EQUAL_FLOAT(same_round_val, multi_round_gap_val);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_dispatch_commit_transitions_job_to_run);
RUN_TEST(test_dispatch_commit_survives_round_boundary_before_confirmation);
RUN_TEST(test_dispatch_commit_survives_several_rounds_before_confirmation);
RUN_TEST(test_dispatch_commit_multi_round_gap_matches_same_round_confirmation);
RUN_TEST(test_pend_decision_does_not_touch_job_state);
return UNITY_END();
}