From 4644d20cf09b3513e9e8011478c1b97e0de5ca81 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sun, 6 Sep 2026 06:01:00 +0000 Subject: [PATCH] test(resreq-maxtasks-wiring): verify ingest wiring parity and no-regression Adds test_maxtasks_wiring_ingest.c proving: (1) fresh ingest's dispatched primary resreq reflects a real multi-task job's requested count via the real core_build_job()/JOBS_PUSH path; (2) fresh ingest and snapshot-restore ingest derive the identical task count for an equivalent job, asserted both against expected constants and via direct cross-comparison (Requirement 5.6); (3) a single-task job reaches identical real host-selection/allocation behavior through the live allocator dispatch chain, proving the feature is a true no-op for maxTasks==1 (Requirements 3.1/3.2). No production code changed -- tasks 2.1/2.2 already wired the fix; this adds cross-call-site and behavioral proof neither of those isolated tests attempted. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XYdtrEWWZfKRRGED7raEYg --- .kiro/specs/resreq-maxtasks-wiring/tasks.md | 2 +- src/scheduler/testsuite/meson.build | 33 + .../testsuite/test_maxtasks_wiring_ingest.c | 794 ++++++++++++++++++ 3 files changed, 828 insertions(+), 1 deletion(-) create mode 100644 src/scheduler/testsuite/test_maxtasks_wiring_ingest.c diff --git a/.kiro/specs/resreq-maxtasks-wiring/tasks.md b/.kiro/specs/resreq-maxtasks-wiring/tasks.md index 2a01a4b4..12a25738 100644 --- a/.kiro/specs/resreq-maxtasks-wiring/tasks.md +++ b/.kiro/specs/resreq-maxtasks-wiring/tasks.md @@ -31,7 +31,7 @@ - _Boundary: test_worker_launch_type_dispatch.c_ - [ ] 3. Integration: verify the ingest wiring end-to-end -- [ ] 3.1 (P) Verify both live ingest call sites, snapshot-vs-fresh parity, and single-task-job no-regression +- [x] 3.1 (P) Verify both live ingest call sites, snapshot-vs-fresh parity, and single-task-job no-regression - Write a white-box test proving fresh job-ingest's dispatched resource request reflects the real requested task count for a multi-task job - Write a white-box test proving snapshot-restore ingest derives the identical task count an equivalent fresh submission would, directly satisfying the comparative claim between the two paths - Write a test proving a job requesting exactly one task (or no explicit count) produces identical host-selection and dispatch behavior through this wiring as it did before this feature diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index b8fa8e94..5e945ee5 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -1928,3 +1928,36 @@ sched_parjob_e2e_multislot_test = executable( test('scheduler parjob e2e multislot test', sched_parjob_e2e_multislot_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) + +# resreq-maxtasks-wiring spec, task 3.1 (Req 1.1, 1.3, 3.1, 3.2, 5.6): the +# dedicated integration test file for this feature's two live ingest call +# sites -- fresh ingest reflects a real multi-task job's requested task +# count (Req 1.1), fresh ingest and snapshot-restore ingest derive the +# IDENTICAL task count for an equivalent job (Req 1.3/5.6's own comparative +# claim), and a single-task job's real host-selection/dispatch outcome is +# unaffected by this feature (Req 3.1/3.2). Same real-scheduler_srcs link +# technique as sched_parjob_e2e_multislot_test/sched_snapshot_load_ +# allocator_regression_test above (see this test file's own header comment +# for the full rationale -- one link technique for the whole file avoids a +# duplicate-symbol conflict with meson's own separate compilation of +# core.c/core_events.c/snapshot_load.c below). +sched_maxtasks_wiring_ingest_test_srcs = [ + 'test_maxtasks_wiring_ingest.c'] +foreach f : scheduler_srcs + if f != 'src/main.c' + sched_maxtasks_wiring_ingest_test_srcs += ['../' + f] + endif +endforeach + +sched_maxtasks_wiring_ingest_test = executable( + 'sched_maxtasks_wiring_ingest_test', + sched_maxtasks_wiring_ingest_test_srcs, + include_directories:[incdir], + dependencies: [scheduler_deps, unity_dep], + link_args: ['-Wl,--wrap=xcecli_start', + '-Wl,--wrap=xcecli_join'], + install: false) + +test('scheduler maxtasks wiring ingest test', + sched_maxtasks_wiring_ingest_test, suite: 'scheduler', + env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/scheduler/testsuite/test_maxtasks_wiring_ingest.c b/src/scheduler/testsuite/test_maxtasks_wiring_ingest.c new file mode 100644 index 00000000..300fff38 --- /dev/null +++ b/src/scheduler/testsuite/test_maxtasks_wiring_ingest.c @@ -0,0 +1,794 @@ +/* + * test_maxtasks_wiring_ingest.c - resreq-maxtasks-wiring spec, task 3.1 + * (Req 1.1, 1.3, 3.1, 3.2, 5.6): the dedicated integration test file for + * this feature's two live ingest call sites (`core_build_job()`, + * core_events.c; the snapshot-restore path, snapshot_load.c), proving three + * things NEITHER task 2.1's own test_core_events.c additions + * (test_core_build_job_wires_real_task_count_into_resreq_primary/ + * test_core_build_job_default_task_count_still_one) NOR task 2.2's own + * test_snapshot_save_load.c addition + * (test_snapshot_load_wires_real_task_count_into_resreq_primary) attempted: + * + * 1. Fresh ingest reflects a real multi-task job's requested task count + * on the standalone primary the allocator dispatch actually reads + * (Req 1.1) -- a fresh, independent proof point alongside task 2.1's + * own, not a duplicate of it (different task-count values, and this + * file additionally asserts job->resreq->u.primary and + * job->schedobj->u.primary->resreq are the SAME pointer, i.e. that + * what dispatch reads (scheduler.c:315, + * job->schedobj->u.primary->resreq) really is the object this + * feature's helper mutated). + * 2. Fresh ingest and snapshot-restore ingest, given an EQUIVALENT job + * (same mintasks/maxtasks/resreq), derive the IDENTICAL standalone + * primary task count -- Req 5.6's own comparative claim ("shown to + * derive the same ... task count as it would through fresh + * submission"), asserted directly (fresh value == restored value), not + * just each independently checked against the expected constant. This + * is genuinely new: task 2.1's and task 2.2's own tests each check + * their own call site in isolation and never compare the two against + * each other. + * 3. A single-task job (maxTasks==1, the common case, indistinguishable + * at this layer from "no explicit task count" -- see note on + * test_single_task_job_dispatch_and_host_selection_unchanged_via_real_ + * allocator() below) reaches a real host-selection/allocation + * decision through the REAL, live allocator dispatch chain + * (sched_init()'s real sched_allocator() thread -> + * sched_make_primary_alloc_() -> seqjob_allocator) with the SAME + * single-host, non-split outcome production always produced before + * this feature existed -- Req 3.1/3.2's no-regression guarantee, + * proven at the BEHAVIORAL (host-selection/dispatch) level, not merely + * by inspecting the resreq struct's own minTasks/maxTasks fields (task + * 2.1's/task 2.2's own default-case tests only ever did the latter). + * + * ── Link technique ────────────────────────────────────────────────────── + * All three tests in this file link the REAL, whole `scheduler_srcs` object + * list (meson.build), the same established precedent as + * test_parjob_e2e_multislot.c / test_snapshot_load_allocator_regression.c + * (see either file's own header comment for the full rationale: reaching + * scheduler.c's `static` sched_make_allocation()/sched_make_primary_alloc_() + * dispatch chain requires either white-box #include-ing scheduler.c + * directly -- ruled out project-wide for scheduler.c specifically, its own + * dependency surface being too large -- or linking the real, separately- + * compiled scheduler_srcs and driving them through sched_init()'s real, + * public entry point). Using ONE link technique for the whole file (rather + * than mixing this with test_core_events.c's/test_snapshot_save_load.c's + * own white-box #include-of-core.c-family technique) avoids a duplicate- + * symbol link error that would result from #include-ing core.c/ + * core_events.c/snapshot_load.c directly INTO this TU while meson ALSO + * compiles those same files as their own separate translation units for + * the executable below. + * + * A useful, incidental consequence: since every function this file calls + * (xce_core_push_events(), xce_sched_core_init(), xce_sched_core_load_ + * snapshot(), sched_init()) is the REAL, separately-linked production + * object -- not a white-box #include with this directory's usual hand- + * written stub surface (kill_sched_*, plugin_prepare_allocation, + * sched_ctx_get_uncomit_host, etc.) -- this file needs NONE of those stubs; + * only the two `-Wl,--wrap=` doubles for xcecli_start()/xcecli_join() any + * real-scheduler_srcs test in this directory needs, so sched_init() (test 3 + * only) succeeds with no live controller. + * + * Tests 1 and 2 use a bare `xce_sched_core_init()`'d core (no picker/ + * allocator threads started -- same technique test_snapshot_load_allocator_ + * regression.c's own build_source_core_with_pending_job() helper already + * establishes for a throwaway core), since they only need to inspect the + * resreq the ingest call site itself builds, never dispatch. Test 3 alone + * uses the heavier real `sched_init()` (starts the real picker/allocator + * threads) since it is the one test in this file that must observe a real + * allocator dispatch decision. Mixing a bare xce_sched_core_init()'d core + * with a separate, fully sched_init()'d scheduler in the same file/process + * is itself an established, safe combination -- test_snapshot_load_ + * allocator_regression.c's own single test function already does exactly + * this (its throwaway `core_src` alongside its real `sched`). + * + * ── RED-phase verification performed while writing this file (not + * committed here) ────────────────────────────────────────────────────── + * Since the production wiring (task 2.1/2.2, commits 0aadb494/6577e041) is + * already merged, this file's own tests pass against current code by + * construction -- there is no new production bug for them to catch going + * forward. To confirm each is still a MEANINGFUL, non-tautological + * assertion (rather than one that would pass regardless of whether the fix + * exists), both call sites' `apply_job_task_count(...)` lines were + * temporarily commented out (core_events.c, snapshot_load.c) and this + * file's binary rebuilt and re-run in isolation: + * - test_fresh_ingest_reflects_real_multi_task_job_task_count: FAILED + * (primary->minTasks/maxTasks read back 1/1, parse_resreq_primary()'s + * bare hardcode, instead of the requested 3/5) -- confirms this + * assertion is a real, meaningful proof of Req 1.1, not a tautology. + * - test_fresh_ingest_and_snapshot_restore_derive_identical_task_count_ + * for_equivalent_job: FAILED on its expected-constant assertions (both + * paths read back 1/1 instead of the requested 2/9) -- note the + * fresh==restored CROSS-comparison alone would NOT have caught this + * regression (both paths degrade to the SAME wrong 1/1 default without + * the fix, so they'd still equal each other); this is exactly why this + * test asserts both the expected-constant form AND the cross-path + * equality form together, not the cross-comparison alone. + * - test_single_task_job_dispatch_and_host_selection_unchanged_via_real_ + * allocator: PASSED unchanged either way -- confirms by direct + * empirical measurement (not merely by code-reading argument) that a + * maxTasks==1 job's real dispatch outcome is genuinely a no-op change + * for this feature, since minTasks=maxTasks=1 is exactly what parse_ + * resreq_primary()'s own pre-existing hardcode already produced. + * All three lines were restored immediately afterward (git-verified clean + * diff against core_events.c/snapshot_load.c) before this file's own + * implementation was finalized. + */ +#include +#include +#include +#include +#include + +#include + +#include "client.h" +#include "controller.capnp.h" +#include "core.h" +#include "decision.h" +#include "preason.h" +#include "resobj.h" +#include "resreq.h" +#include "sched/alloc_cache.h" +#include "scheduler.h" +#include "xce/cluster.h" +#include "xstring.h" + +extern int scheduler_req_handler(xce_request_t*, void*); + +#define WORKER_C_NAME "maxtasks-wiring-host-c" +#define WORKER_D_NAME "maxtasks-wiring-host-d" +#define WORKER_SLOTS 1.0f +#define SINGLE_TASK_RESREQ_STR ((char*)"rusage[mem=1]") + +#define JOBID_FRESH_MULTI ((uint64_t)910101ULL) +#define JOBID_PARITY ((uint64_t)910201ULL) +#define JOBID_SINGLE_TASK ((uint64_t)910301ULL) + +/* ════════════════════════════════════════════════════════════════════════ + * __wrap_xcecli_start / __wrap_xcecli_join -- trivial always-succeed + * doubles, identical rationale/shape to every other real-scheduler_srcs + * test in this directory (test_parjob_e2e_multislot.c/test_snapshot_load_ + * allocator_regression.c): the one real sched_init() call in this file + * (test 3 only) must succeed with no live controller so the real picker/ + * allocator threads actually start. + * ════════════════════════════════════════════════════════════════════════ */ + +int __wrap_xcecli_start(xcecli_ctx_t c) +{ + (void)c; + return 0; +} + +int __wrap_xcecli_join(xcecli_ctx_t c, int role, hostattrs_t* attrs) +{ + (void)c; + (void)role; + (void)attrs; + return 0; +} + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* ---------------------------------------------------------------------- */ +/* Fixture helpers */ +/* ---------------------------------------------------------------------- */ + +/* make_wire_queue -- a real xce_queue_t wire payload, reused both for a + * QUEUE_UPDATE ingest event (tests 1/2's fresh-ingest side) and for a + * snapshot wire payload's `queues` array (test 2's restore side). Same + * shape as test_parjob_e2e_multislot.c's own helper of the same name -- + * see that file's header comment (bug-1771) for why a real queue + * registration matters once a DISPATCH decision is ever produced; not + * strictly required for tests 1/2 (which never reach dispatch), but kept + * for parity/hygiene with the rest of this directory's real-ingest + * fixtures. Never torn down, same "fixtures aren't torn down" convention + * this whole directory follows. */ +static xce_queue_t* make_wire_queue(const char* name, int32_t priority) +{ + xce_queue_t* q = (xce_queue_t*)calloc(1, sizeof(*q)); + + TEST_ASSERT_NOT_NULL(q); + + q->queue = strdup(name); + q->status = QUEUE_STAT_OPEN; + q->priority = priority; + + return q; +} + +/* make_push_job -- a heap-allocated xce_job_t/job_submit_data_t pair, ready + * for EITHER a real XCE_CORE_EVENT_JOBS_PUSH ingest event (core_build_job()) + * OR a snapshot wire payload's `jobs` array (xce_sched_core_load_snapshot(), + * which reads the identical job_submit_data_t fields). Same field shapes + * test_parjob_e2e_multislot.c's own make_push_job()/test_snapshot_save_ + * load.c's own make_job_with_resreq() each independently establish -- this + * is the one place this file needs BOTH shapes to be literally the same + * struct, since test 2's whole point is driving an equivalent job through + * both ingest mechanisms. Never torn down, same convention. */ +static xce_job_t* make_push_job(uint64_t jobid, char* resreq_str, char* user, + char* queue, char* project, uint32_t mintasks, + uint32_t maxtasks) +{ + xce_job_t* j = (xce_job_t*)calloc(1, sizeof(*j)); + + TEST_ASSERT_NOT_NULL(j); + + j->jobid = jobid; + j->state = JOB_STAT_PEND; + j->sub_time = 1000; + j->queue_time = 1000; + + j->submit = (job_submit_data_t*)calloc(1, sizeof(*(j->submit))); + TEST_ASSERT_NOT_NULL(j->submit); + + j->submit->queue = queue; + j->submit->user = user; + j->submit->project_name = project; + j->submit->resreq = resreq_str; + j->submit->mintasks = mintasks; + j->submit->maxtasks = maxtasks; + + return j; +} + +/* make_wire_worker -- a real xce_worker_t wire payload carrying one + * "slots" resval, ready for a real XCE_CORE_EVENT_WORKERS_PUSH ingest. + * Identical shape to test_parjob_e2e_multislot.c's own helper of the same + * name. Only needed by test 3 (the only test in this file that dispatches + * against real workers). Never torn down, same convention. */ +static xce_worker_t* make_wire_worker(const char* name, float slots) +{ + xce_worker_t* w = (xce_worker_t*)calloc(1, sizeof(*w)); + xce_val_t* val = (xce_val_t*)calloc(1, sizeof(*val)); + xce_name_val_t* nv = (xce_name_val_t*)calloc(1, sizeof(*nv)); + xce_name_val_t** nv_ptrs = (xce_name_val_t**)calloc(1, sizeof(*nv_ptrs)); + + TEST_ASSERT_NOT_NULL(w); + TEST_ASSERT_NOT_NULL(val); + TEST_ASSERT_NOT_NULL(nv); + TEST_ASSERT_NOT_NULL(nv_ptrs); + + val->which = Value_u_fvalue; + val->u.fvalue = slots; + + nv->name = strdup("slots"); + nv->val = val; + nv_ptrs[0] = nv; + + w->name = strdup(name); + w->status = 0; + w->nresvals_to_sched = 1; + w->resvals_to_sched = nv_ptrs; + + return w; +} + +/* register_slots_resource -- identical helper/rationale to + * test_parjob_e2e_multislot.c's/test_seqjob_allocator.c's own: seqjob_ + * prepare_allocation()'s real key_find(sched->core.resmgr, "slots", ...) + * call needs "slots" to be a genuinely registered resource. Only needed by + * test 3. */ +static xce_resource_t* register_slots_resource(var resmgr) +{ + xce_resource_t res; + + memset(&res, 0, sizeof(res)); + res.name = strdup("slots"); + + return makeResource(resmgr, &res); +} + +/* start_sched -- real sched_init() (registers the real builtin_fcfs/ + * seqjob_allocator/parjob_allocator plugins and starts the real + * sched_job_picker()/sched_allocator() threads), plus the real per-session + * cache-clear hook registration main.c itself performs -- identical + * rationale/shape to test_parjob_e2e_multislot.c's own helper of the same + * name. Only needed by test 3. Port 20101 is not otherwise used by any + * other file in this testsuite directory (checked: 19997/19998/19999 are + * the only ones already claimed) -- moot regardless, since xcecli_start()/ + * xcecli_join() are wrapped to no-op, so no real socket is ever bound to + * this port by this fixture. */ +static xce_scheduler_t* start_sched(void) +{ + static xce_client_config_t config; + static xce_cluster_config_t cluster_cfg; + xce_scheduler_t* sched + = (xce_scheduler_t*)calloc(1, sizeof(*sched)); + xce_resource_t* slots_res; + int rc; + + TEST_ASSERT_NOT_NULL(sched); + + memset(&config, 0, sizeof(config)); + config.nmasters = 1; + config.masters[0] = "127.0.0.1"; + config.master_ports[0] = 20101; + config.from = "sched_maxtasks_wiring_ingest_test"; + config.key = "keepsecret$"; + + memset(&cluster_cfg, 0, sizeof(cluster_cfg)); + cluster_cfg.candidates_count = 1; + strncpy(cluster_cfg.candidates[0].hostname, "127.0.0.1", + XCE_MAXHOSTNAME_LEN - 1); + cluster_cfg.candidates[0].port = 20101; + strncpy(cluster_cfg.identity, "sched_maxtasks_wiring_ingest_test", + sizeof(cluster_cfg.identity) - 1); + + rc = sched_init(sched, &config, &cluster_cfg, scheduler_req_handler); + TEST_ASSERT_TRUE_MESSAGE( + rc >= 0, "fixture: sched_init() must succeed (xcecli_start()/" + "xcecli_join() wrapped to succeed with no I/O) so the " + "real picker/allocator threads actually start"); + + slots_res = register_slots_resource(sched->core.resmgr); + TEST_ASSERT_NOT_NULL(slots_res); + + xce_core_set_cache_clear_cb(&(sched->core), xce_alloc_cache_clear_cb); + + return sched; +} + +/* dispatch_and_wait -- bypass the dormant picker thread and feed jobid + * directly into the live allocator thread's input queue, then wait + * (bounded, non-blocking poll) for the resulting decision. Identical + * technique to test_parjob_e2e_multislot.c's/test_snapshot_load_allocator_ + * regression.c's own helper of the same name. Only needed by test 3. */ +static xce_decision_t* dispatch_and_wait(xce_scheduler_t* sched, + uint64_t jobid) +{ + xce_decision_t* decision = NULL; + int rc; + int waited_ms; + + rc = cbuf_tryput(&(sched->core.jbuf), &jobid); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: jbuf must have room"); + + for (waited_ms = 0; waited_ms < 2000; waited_ms += 5) { + struct timespec ts = { 0, 5 * 1000 * 1000 }; + + if (cbuf_tryget(&(sched->core.decisions), &decision) >= 0) { + return decision; + } + nanosleep(&ts, NULL); + } + + return NULL; +} + +/* ---------------------------------------------------------------------- */ +/* Test 1 -- Req 1.1: fresh ingest reflects the real multi-task count */ +/* ---------------------------------------------------------------------- */ + +/* + * A real job submitted with mintasks=3/maxtasks=6 (deliberately different + * from task 2.1's own 4/6 fixture, so this is a genuinely independent data + * point, not a copy) is pushed through the REAL, live core_build_job() + * ingest path (XCE_CORE_EVENT_JOBS_PUSH). Confirms the standalone primary's + * minTasks/maxTasks reflect the real requested count, AND confirms + * job->schedobj->u.primary->resreq (what scheduler.c's real dispatch reads, + * scheduler.c:315) is the SAME object as job->resreq->u.primary (what task + * 2.1's own test checked) -- i.e. that this feature's fix genuinely reaches + * the dispatch-visible resreq, not merely a copy of it that dispatch never + * sees. + */ +static void test_fresh_ingest_reflects_real_multi_task_job_task_count(void) +{ + xce_sched_core_t core; + xce_queue_t* queue; + xce_core_event_t queue_event; + xce_job_t* job; + xce_job_t* job_ptrs[1]; + xce_core_event_t job_event; + xce_sched_job_t* sjob; + xce_primary_resreq_t* primary; + int rc; + + rc = xce_sched_core_init(&core); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: xce_sched_core_init() must " + "succeed"); + + queue = make_wire_queue("normal", 1); + memset(&queue_event, 0, sizeof(queue_event)); + queue_event.type = XCE_CORE_EVENT_QUEUE_UPDATE; + queue_event.u.queue_update.queue = queue; + rc = xce_core_push_events(&core, &queue_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real QUEUE_UPDATE ingest " + "must succeed"); + + job = make_push_job(JOBID_FRESH_MULTI, "type == any", "alice", "normal", + "proj1", 3, 6); + job_ptrs[0] = job; + + memset(&job_event, 0, sizeof(job_event)); + job_event.type = XCE_CORE_EVENT_JOBS_PUSH; + job_event.u.jobs_push.njobs = 1; + job_event.u.jobs_push.jobs = job_ptrs; + + rc = xce_core_push_events(&core, &job_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "real JOBS_PUSH ingest must succeed"); + + sjob = sched_get_job(&core, JOBID_FRESH_MULTI); + TEST_ASSERT_NOT_NULL(sjob); + TEST_ASSERT_NOT_NULL_MESSAGE(sjob->resreq, "core_build_job() must have " + "parsed a resreq"); + TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob->resreq->tag); + TEST_ASSERT_NOT_NULL(sjob->resreq->u.primary); + TEST_ASSERT_NOT_NULL_MESSAGE( + sjob->schedobj, "core_build_job() must have built job->schedobj"); + TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob->schedobj->tag); + TEST_ASSERT_NOT_NULL(sjob->schedobj->u.primary); + + /* The Req 1.1 proof: the real submitted task count reached the + * standalone primary's minTasks/maxTasks, not parse_resreq_primary()'s + * own always-1 default. */ + primary = sjob->resreq->u.primary; + TEST_ASSERT_EQUAL_INT(3, primary->minTasks); + TEST_ASSERT_EQUAL_INT(6, primary->maxTasks); + + /* The dispatch-reachability proof: what scheduler.c's real dispatch + * actually reads (job->schedobj->u.primary->resreq) is the SAME + * xce_primary_resreq_t object this feature's helper mutated (job-> + * resreq->u.primary) -- not an independent copy that would leave + * dispatch still seeing the stale 1/1 default. */ + TEST_ASSERT_TRUE_MESSAGE( + sjob->schedobj->u.primary->resreq == primary, + "job->schedobj->u.primary->resreq (what the real allocator dispatch " + "reads, scheduler.c:315) must be the exact same object as " + "job->resreq->u.primary (what apply_job_task_count() mutated) -- " + "otherwise the fix would be invisible to dispatch"); + TEST_ASSERT_EQUAL_INT(3, sjob->schedobj->u.primary->resreq->minTasks); + TEST_ASSERT_EQUAL_INT(6, sjob->schedobj->u.primary->resreq->maxTasks); +} + +/* ---------------------------------------------------------------------- */ +/* Test 2 -- Req 1.3 / 5.6: fresh-ingest vs snapshot-restore parity */ +/* ---------------------------------------------------------------------- */ + +/* + * The SAME job spec (mintasks=2/maxtasks=9, resreq "type == any") is driven + * through BOTH live ingest call sites independently: fresh ingest + * (core_build_job(), via a real XCE_CORE_EVENT_JOBS_PUSH) into one bare + * core, and snapshot restore (xce_sched_core_load_snapshot(), the + * simulation/replay driver's own load path) into a SEPARATE, independently + * xce_sched_core_init()'d core. Req 5.6's comparative claim -- snapshot + * restore "shown to derive the same standalone primary resource request + * task count as it would through fresh submission" -- is asserted directly + * (fresh == restored), in addition to each path's own value being checked + * against the expected constant (the constant checks are what make the + * comparison meaningful rather than two independently-wrong values + * happening to agree -- see this file's own header comment's RED-phase + * note). + */ +static void +test_fresh_ingest_and_snapshot_restore_derive_identical_task_count_for_equivalent_job(void) +{ + xce_sched_core_t core_fresh; + xce_sched_core_t core_restored; + xce_queue_t* queue_fresh; + xce_core_event_t queue_event; + xce_job_t* fresh_job; + xce_job_t* fresh_job_ptrs[1]; + xce_core_event_t job_event; + xce_sched_job_t* fresh_sjob; + xce_primary_resreq_t* fresh_primary; + + xce_queue_t* restore_queue; + xce_queue_t* restore_queues[1]; + xce_job_t* restore_job; + xce_job_t* restore_jobs[1]; + xce_jobs_data_t jobs_data; + sched_snapshot_data_t wire_in; + xce_sched_job_t* restored_sjob; + xce_primary_resreq_t* restored_primary; + + int rc; + + /* ── Path A: fresh ingest ────────────────────────────────────────── */ + rc = xce_sched_core_init(&core_fresh); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: xce_sched_core_init() must " + "succeed for the fresh-ingest core"); + + queue_fresh = make_wire_queue("normal", 1); + memset(&queue_event, 0, sizeof(queue_event)); + queue_event.type = XCE_CORE_EVENT_QUEUE_UPDATE; + queue_event.u.queue_update.queue = queue_fresh; + rc = xce_core_push_events(&core_fresh, &queue_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real QUEUE_UPDATE ingest " + "must succeed on the fresh core"); + + fresh_job = make_push_job(JOBID_PARITY, "type == any", "alice", "normal", + "proj1", 2, 9); + fresh_job_ptrs[0] = fresh_job; + + memset(&job_event, 0, sizeof(job_event)); + job_event.type = XCE_CORE_EVENT_JOBS_PUSH; + job_event.u.jobs_push.njobs = 1; + job_event.u.jobs_push.jobs = fresh_job_ptrs; + + rc = xce_core_push_events(&core_fresh, &job_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real JOBS_PUSH ingest must " + "succeed on the fresh core"); + + fresh_sjob = sched_get_job(&core_fresh, JOBID_PARITY); + TEST_ASSERT_NOT_NULL(fresh_sjob); + TEST_ASSERT_NOT_NULL(fresh_sjob->resreq); + TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, fresh_sjob->resreq->tag); + fresh_primary = fresh_sjob->resreq->u.primary; + TEST_ASSERT_NOT_NULL(fresh_primary); + + /* ── Path B: snapshot restore (the simulation/replay driver's own + * load path) -- an EQUIVALENT job (same jobid/mintasks/maxtasks/ + * resreq/queue), built directly as a wire payload (same technique + * test_snapshot_save_load.c's own test_snapshot_load_wires_real_task_ + * count_into_resreq_primary() uses: no real xce_sched_core_save_ + * snapshot() round-trip needed, since the wire shape IS the input + * xce_sched_core_load_snapshot() itself consumes), loaded into a + * SEPARATE, independently-initialized core. ────────────────────── */ + rc = xce_sched_core_init(&core_restored); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: xce_sched_core_init() must " + "succeed for the restore-target core"); + + restore_queue = make_wire_queue("normal", 1); + restore_queues[0] = restore_queue; + + restore_job = make_push_job(JOBID_PARITY, "type == any", "alice", + "normal", "proj1", 2, 9); + restore_jobs[0] = restore_job; + + memset(&jobs_data, 0, sizeof(jobs_data)); + jobs_data.count = 1; + jobs_data.jobs = restore_jobs; + + memset(&wire_in, 0, sizeof(wire_in)); + wire_in.jobs = &jobs_data; + wire_in.queues = restore_queues; + wire_in.n_queues = 1; + + rc = xce_sched_core_load_snapshot(&core_restored, &wire_in); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "xce_sched_core_load_snapshot() must " + "succeed"); + + restored_sjob = sched_get_job(&core_restored, JOBID_PARITY); + TEST_ASSERT_NOT_NULL(restored_sjob); + TEST_ASSERT_NOT_NULL(restored_sjob->resreq); + TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, restored_sjob->resreq->tag); + restored_primary = restored_sjob->resreq->u.primary; + TEST_ASSERT_NOT_NULL(restored_primary); + + /* ── Expected-constant checks (what make the comparison below + * meaningful, not vacuous) ──────────────────────────────────────── */ + TEST_ASSERT_EQUAL_INT_MESSAGE( + 2, fresh_primary->minTasks, + "fresh ingest must derive the job's real minTasks"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 9, fresh_primary->maxTasks, + "fresh ingest must derive the job's real maxTasks"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 2, restored_primary->minTasks, + "snapshot restore must derive the job's real minTasks"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 9, restored_primary->maxTasks, + "snapshot restore must derive the job's real maxTasks"); + + /* ── Req 5.6's own comparative claim, asserted directly ──────────── */ + TEST_ASSERT_EQUAL_INT_MESSAGE( + fresh_primary->minTasks, restored_primary->minTasks, + "Req 5.6: snapshot-restore ingest must derive the IDENTICAL " + "standalone primary minTasks an equivalent fresh submission would"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + fresh_primary->maxTasks, restored_primary->maxTasks, + "Req 5.6: snapshot-restore ingest must derive the IDENTICAL " + "standalone primary maxTasks an equivalent fresh submission would"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 3 -- Req 3.1 / 3.2: single-task job, no dispatch regression */ +/* ---------------------------------------------------------------------- */ + +/* + * A job requesting exactly one task (mintasks=maxtasks=1) is driven through + * the REAL, live allocator dispatch chain (sched_init()'s real + * sched_allocator() thread -> the real, static sched_make_primary_alloc_() + * choke point -> seqjob_allocator, since maxTasks==1) against two real + * 1.0-slot workers. Note: at the C layer, "no explicit task count" and "an + * explicit count of one" are indistinguishable -- job_submit_data_t only + * ever carries mintasks/maxtasks, and the apiserver's own validation + * (checkNumProcessors()/convert.go's clamp, outside this spec's boundary) + * already normalizes an absent count to 1 before a job ever reaches this + * layer, the same way task 2.1's own test_core_build_job_default_task_ + * count_still_one() (test_core_events.c) established for the field-level + * check -- so this one dispatch-level scenario faithfully covers both + * phrasings of Req 3.1/3.2 (a bare "1" is what "no explicit count" looks + * like once it has already reached the C layer). + * + * Since apply_job_task_count(resreq, 1, 1) writes back exactly the same + * (1, 1) pair parse_resreq_primary()'s own pre-existing hardcode already + * produced, this is a no-op change by construction -- the point of this + * test is to catch an ACCIDENTAL behavioral regression (e.g. a bug in the + * helper, or an ordering mistake at the call site corrupting resreq_fp or + * something else downstream reads), not to discover an intentional one. A + * clean pass -- exactly one of the two real workers claimed, with exactly + * 1.0 slots allocated, never a 2-host split -- is the correct, expected + * outcome (confirmed empirically to hold identically with the fix's own + * apply_job_task_count() call temporarily removed -- see this file's own + * header comment's RED-phase note). + */ +static void +test_single_task_job_dispatch_and_host_selection_unchanged_via_real_allocator(void) +{ + xce_scheduler_t* sched = start_sched(); + xce_queue_t* queue; + xce_core_event_t queue_event; + xce_worker_t* worker_ptrs[2]; + xce_core_event_t worker_event; + xce_job_t* job; + xce_job_t* job_ptrs[1]; + xce_core_event_t job_event; + xce_sched_job_t* sjob; + xce_primary_resreq_t* primary; + xce_decision_t* decision; + int rc; + int claimed_host_count = 0; + int saw_c = 0; + int saw_d = 0; + double total_slots = 0.0; + + /* ── Real queue + worker ingest ───────────────────────────────────── */ + queue = make_wire_queue("normal", 1); + memset(&queue_event, 0, sizeof(queue_event)); + queue_event.type = XCE_CORE_EVENT_QUEUE_UPDATE; + queue_event.u.queue_update.queue = queue; + rc = xce_core_push_events(&(sched->core), &queue_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real QUEUE_UPDATE ingest " + "must succeed"); + + worker_ptrs[0] = make_wire_worker(WORKER_C_NAME, WORKER_SLOTS); + worker_ptrs[1] = make_wire_worker(WORKER_D_NAME, WORKER_SLOTS); + + memset(&worker_event, 0, sizeof(worker_event)); + worker_event.type = XCE_CORE_EVENT_WORKERS_PUSH; + worker_event.u.workers_push.nworkers = 2; + worker_event.u.workers_push.workers = worker_ptrs; + + rc = xce_core_push_events(&(sched->core), &worker_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real WORKERS_PUSH ingest " + "must succeed"); + TEST_ASSERT_NOT_NULL(sched_get_worker(&(sched->core), WORKER_C_NAME)); + TEST_ASSERT_NOT_NULL(sched_get_worker(&(sched->core), WORKER_D_NAME)); + + /* ── Real job ingest (single-task) ───────────────────────────────── */ + job = make_push_job(JOBID_SINGLE_TASK, SINGLE_TASK_RESREQ_STR, + "single_task_user", "normal", "single_task_proj", 1, + 1); + job_ptrs[0] = job; + + memset(&job_event, 0, sizeof(job_event)); + job_event.type = XCE_CORE_EVENT_JOBS_PUSH; + job_event.u.jobs_push.njobs = 1; + job_event.u.jobs_push.jobs = job_ptrs; + + rc = xce_core_push_events(&(sched->core), &job_event, 1); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: real JOBS_PUSH ingest must " + "succeed"); + + sjob = sched_get_job(&(sched->core), JOBID_SINGLE_TASK); + TEST_ASSERT_NOT_NULL(sjob); + TEST_ASSERT_NOT_NULL(sjob->schedobj); + TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob->schedobj->tag); + + primary = sjob->schedobj->u.primary->resreq; + TEST_ASSERT_NOT_NULL(primary); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, primary->minTasks, + "a single-task job's standalone primary minTasks must still " + "resolve to 1 -- this feature must be a no-op for this case"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, primary->maxTasks, + "a single-task job's standalone primary maxTasks must still " + "resolve to 1 -- this feature must be a no-op for this case, and " + "this is also the dispatch gate that routes to seqjob_allocator " + "rather than parjob_allocator (scheduler.c)"); + + /* ── Real per-session begin + drive through the REAL, live allocator */ + rc = xce_core_begin_session(&(sched->core)); + TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: xce_core_begin_session() " + "must succeed"); + + decision = dispatch_and_wait(sched, JOBID_SINGLE_TASK); + + TEST_ASSERT_NOT_NULL_MESSAGE( + decision, "the real allocator must produce a decision for the " + "single-task job within 2s"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + XCE_DECISION_DISPATCH, decision->op, + "a single-task job must DISPATCH -- two real 1.0-slot workers each " + "trivially satisfy a 1-slot want"); + + TEST_ASSERT_NOT_NULL(decision->u.alloc); + TEST_ASSERT_EQUAL_UINT64(JOBID_SINGLE_TASK, decision->u.alloc->jobid); + + /* The Req 3.1/3.2 proof: exactly ONE host claimed, never a split + * across both -- the observable that would change if maxTasks had + * been accidentally corrupted to something other than 1 (which would + * route to parjob_allocator instead and could span both workers). */ + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, decision->u.alloc->nhosts, + "a single-task job's committed allocation must span EXACTLY one " + "host -- a value other than 1 here would mean this job was " + "accidentally routed to the multi-host parjob_allocator path " + "instead of the single-host seqjob_allocator path this feature " + "must leave unaffected"); + + { + int i; + + for (i = 0; i < decision->u.alloc->nhosts; i++) { + xce_host_alloc_t* h = decision->u.alloc->allocs[i]; + int j; + int found_slots = 0; + + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_NOT_NULL(h->host); + claimed_host_count++; + + if (strcmp(h->host, WORKER_C_NAME) == 0) { + saw_c = 1; + } else if (strcmp(h->host, WORKER_D_NAME) == 0) { + saw_d = 1; + } else { + TEST_FAIL_MESSAGE( + "claimed host is neither of the two real ingested " + "workers"); + } + + for (j = 0; j < h->nallocs; j++) { + xce_alloc_t* a = h->allocs[j]; + + TEST_ASSERT_NOT_NULL(a); + if ((a->resname != NULL) + && (strcmp(a->resname, "slots") == 0)) { + found_slots = 1; + TEST_ASSERT_EQUAL_FLOAT_MESSAGE( + 1.0f, (float)a->value, + "the single claimed host's own slots allocation " + "must be exactly 1.0 -- the job's full (and only) " + "requested task, not some other value"); + total_slots += a->value; + } + } + + TEST_ASSERT_TRUE_MESSAGE( + found_slots, "the claimed host must carry a real 'slots' " + "allocation entry"); + } + } + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, claimed_host_count, + "exactly one host must appear in the committed allocation"); + TEST_ASSERT_TRUE_MESSAGE( + saw_c || saw_d, + "the single claimed host must be one of the two real ingested " + "workers"); + TEST_ASSERT_FALSE_MESSAGE( + saw_c && saw_d, + "a single-task job must never claim BOTH workers -- that would be " + "the multi-host split this feature must not introduce for " + "maxTasks==1"); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE( + 1.0f, (float)total_slots, + "total allocated slots across the (single) claimed host must be " + "exactly 1.0"); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_fresh_ingest_reflects_real_multi_task_job_task_count); + RUN_TEST( + test_fresh_ingest_and_snapshot_restore_derive_identical_task_count_for_equivalent_job); + RUN_TEST( + test_single_task_job_dispatch_and_host_selection_unchanged_via_real_allocator); + return UNITY_END(); +}