/* * 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. * * ── Task 3.2 addendum -- two more tests, same file/link technique ──────── * Task 3.2 (Req 1.4, 5.4, and design.md's "Project Invariant Cross-Check") * appends two MORE tests to this same file, reusing every fixture helper * above. (A third task-3.2 test, proving driver/simulation ingest parity * via the REAL, `static` simulation-driver translator chain, lives in its * own separate file, test_maxtasks_wiring_sim_driver_parity.c -- see that * file's own header comment for why: an independent review rejected this * file's original mirrored version of that test and required white-box * #include-ing sim_loop.c's real translator instead, which cannot share a * translation unit with this file's own test 3, below, since that test * links the whole real scheduler_srcs object list.) * * 4. test_resource_request_update_never_changes_dispatched_task_count: * Req 1.4's structural regression test. A job is ingested fresh, then * a real XCE_CORE_EVENT_JOB_UPDATE is driven for the SAME jobid * carrying BOTH a different resreq string AND a different requested * task count. Confirms job->resreq WAS genuinely replaced (so the * immunity assertion below isn't vacuous), then confirms * job->schedobj->u.primary->resreq -- what the real allocator dispatch * reads -- is the EXACT SAME object with the EXACT SAME ingest-time * task count, because core_events.c's JOB_UPDATE case never reassigns * job->schedobj (grepped exhaustively: no such assignment exists in * that case block). This is a reasoned, not empirically red/green * -tested, proof -- see design.md's Existing Architecture Analysis: * the guarantee holds today for this structural reason (bug-1773), * independent of any new code this feature adds; there is no existing * production bug for a RED phase to catch here, only a future * regression (job->schedobj being reassigned on update) this test now * guards against. * * 5. test_differing_task_counts_do_not_collide_in_unsatisfiable_cache: * Req 5.4, fresh-ingest path only (snapshot-restore is the documented, * pre-existing bug-1772 exception, out of this test's scope). Two * otherwise-identical jobs, differing ONLY in requested task count, * are ingested fresh into the SAME core (so their resreq_fp's other * components -- select_node_id/rusage_hash -- are guaranteed * identical). Reuses scheduler-allocation's own test_alloc_cache.c * conventions (cache_insert()/cache_lookup() driven directly against * real primary schedobj pointers) combined with this file's own * fresh-ingest technique. Empirically verified meaningful: with * core_events.c's apply_job_task_count() call temporarily commented * out, both jobs collapse to the IDENTICAL resreq_fp and this test * then FAILS one assertion earlier than cache_lookup() itself -- at * its own fingerprint sanity check (job A's and job B's * resreq_fp.min_tasks, expected to differ, are instead both 1) -- * confirmed by temporarily disabling the wiring, rebuilding this * binary in isolation, observing that failure, then reverting to a * git-clean state before finalizing this test. */ #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) /* task 3.2 additions (Req 1.4, 5.4) -- see this file's own top-of-file * header comment's task-3.2 addendum, below the task-3.1 narrative, for * what each of these two tests proves. (The driver/simulation parity test, * design.md's "Project Invariant Cross-Check" commitment, now lives in its * own separate file, test_maxtasks_wiring_sim_driver_parity.c.) */ #define JOBID_UPDATE_IMMUNE ((uint64_t)910501ULL) #define JOBID_CACHE_A ((uint64_t)910601ULL) #define JOBID_CACHE_B ((uint64_t)910602ULL) /* ════════════════════════════════════════════════════════════════════════ * __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"); } /* ---------------------------------------------------------------------- */ /* Test 4 -- Req 1.4: an update never changes the already-ingested task */ /* count (job->schedobj is never rebuilt by JOB_UPDATE) */ /* ---------------------------------------------------------------------- */ /* * Design.md's Existing Architecture Analysis: core_events.c's JOB_UPDATE * handler (live) replaces job->resreq with a freshly parse_resreq()'d * object when updated->submit->resreq is non-NULL, but never reassigns * job->schedobj (grepped exhaustively: no `job->schedobj =` assignment * anywhere in the JOB_UPDATE case, core_events.c:1174-1257) -- so * scheduler.c's real dispatch (job->schedobj->u.primary->resreq, * scheduler.c:315) never sees an update at all, for ANY field, not only * task count (bug-1773). Requirement 1.4's guarantee already holds today * for this structural reason, not because this feature actively re-wires * anything on update (design.md, Non-Goals) -- this test's job is to PROVE * the guarantee holds, not to add new production wiring. * * A real multi-task job (mintasks=4/maxtasks=7, deliberately distinct from * every other test's own fixture values in this file) is ingested fresh, * then a JOB_UPDATE event for the SAME jobid is driven with BOTH a * different resreq string ("rusage[mem=2]" instead of "type == any") AND a * different requested task count (mintasks=1/maxtasks=1) in its own * job_submit_data_t -- exactly the scenario Req 1.4 describes ("a job's * resource request is updated"). Confirms job->resreq WAS genuinely * replaced (proving the update was not silently a no-op, which would make * the immunity assertion below vacuous), and that job->schedobj->u.primary * ->resreq -- what the real allocator dispatch actually reads -- is the * EXACT SAME object, with the SAME minTasks/maxTasks, ingest originally * produced, completely unaffected by the update's own different task * count. * * Meaningfulness (reasoned, per this task's own brief, rather than * empirically red/green-tested -- the failure mode guarded against is a * future production change, not a bug present today): this test would fail * if a future change to the JOB_UPDATE case started reassigning * job->schedobj (e.g. rebuilding it via core_build_schedobj() the way * core_build_job() does), since apply_job_task_count() applied there would * then pick up the UPDATE's own (differing) mintasks/maxtasks rather than * ingest's -- the post-update assertions below would read 1/1 instead of * the expected 4/7. */ static void test_resource_request_update_never_changes_dispatched_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* ingest_time_primary; xce_job_t* update_job; xce_core_event_t update_event; 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_UPDATE_IMMUNE, "type == any", "alice", "normal", "proj1", 4, 7); 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, "fixture: real JOBS_PUSH ingest must " "succeed"); sjob = sched_get_job(&core, JOBID_UPDATE_IMMUNE); TEST_ASSERT_NOT_NULL(sjob); TEST_ASSERT_NOT_NULL(sjob->schedobj); TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob->schedobj->tag); ingest_time_primary = sjob->schedobj->u.primary->resreq; TEST_ASSERT_NOT_NULL(ingest_time_primary); TEST_ASSERT_EQUAL_INT_MESSAGE( 4, ingest_time_primary->minTasks, "fixture: ingest must derive the job's real minTasks before the " "update is driven"); TEST_ASSERT_EQUAL_INT_MESSAGE( 7, ingest_time_primary->maxTasks, "fixture: ingest must derive the job's real maxTasks before the " "update is driven"); /* ── Drive a real JOB_UPDATE: a DIFFERENT resreq string AND a * DIFFERENT requested task count for the SAME job ─────────────────── */ update_job = make_push_job(JOBID_UPDATE_IMMUNE, "rusage[mem=2]", "alice", "normal", "proj1", 1, 1); memset(&update_event, 0, sizeof(update_event)); update_event.type = XCE_CORE_EVENT_JOB_UPDATE; update_event.u.job_update.jobid = JOBID_UPDATE_IMMUNE; update_event.u.job_update.job = update_job; rc = xce_core_push_events(&core, &update_event, 1); TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "real JOB_UPDATE ingest must succeed"); sjob = sched_get_job(&core, JOBID_UPDATE_IMMUNE); TEST_ASSERT_NOT_NULL(sjob); /* Sanity: the update DID genuinely replace job->resreq (not a silent * no-op) -- job->resreq is now a fresh object showing parse_resreq_ * primary()'s own bare 1/1 default (JOB_UPDATE never calls apply_job_ * task_count(), design.md), confirming the assertions below are a * meaningful immunity proof, not a vacuous one from an update that * never took effect. */ TEST_ASSERT_NOT_NULL(sjob->resreq); TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob->resreq->tag); TEST_ASSERT_FALSE_MESSAGE( sjob->resreq->u.primary == ingest_time_primary, "fixture: JOB_UPDATE must have replaced job->resreq with a " "genuinely NEW object, not left the old one in place, for this " "test's immunity assertion to be meaningful"); TEST_ASSERT_EQUAL_INT_MESSAGE( 1, sjob->resreq->u.primary->minTasks, "fixture: job->resreq (freshly reparsed by JOB_UPDATE) must show " "parse_resreq_primary()'s own bare default, confirming JOB_UPDATE " "never calls apply_job_task_count()"); /* ── The Req 1.4 proof: job->schedobj->u.primary->resreq (what the * real allocator dispatch reads, scheduler.c:315) is the EXACT SAME * object, with the EXACT SAME ingest-time task count, unaffected by * the update -- because JOB_UPDATE never reassigns job->schedobj. ──── */ TEST_ASSERT_NOT_NULL(sjob->schedobj); TEST_ASSERT_TRUE_MESSAGE( sjob->schedobj->u.primary->resreq == ingest_time_primary, "job->schedobj->u.primary->resreq must remain the EXACT SAME " "object ingest originally built -- JOB_UPDATE must never rebuild " "job->schedobj"); TEST_ASSERT_EQUAL_INT_MESSAGE( 4, sjob->schedobj->u.primary->resreq->minTasks, "Req 1.4: the job's dispatched minTasks must remain the " "ingest-time value (4), unaffected by the update's own different " "mintasks (1)"); TEST_ASSERT_EQUAL_INT_MESSAGE( 7, sjob->schedobj->u.primary->resreq->maxTasks, "Req 1.4: the job's dispatched maxTasks must remain the " "ingest-time value (7), unaffected by the update's own different " "maxtasks (1)"); } /* ---------------------------------------------------------------------- */ /* Test 5 -- Req 5.4: differing task counts never collide in the */ /* unsatisfiable-resreq cache, on the fresh-ingest path */ /* ---------------------------------------------------------------------- */ /* * Req 5.4 (fresh-ingest path only; snapshot-restore is the documented, * pre-existing bug-1772 exception -- design.md's Testing Strategy). Two * otherwise-identical jobs (same resreq string, same user/queue/project) * that differ ONLY in requested task count must never collide in * scheduler-allocation's unsatisfiable-resreq cache (sched/alloc_cache.h). * core_build_primary_schedobj() (core_events.c) computes primary-> * resreq_fp.min_tasks/max_tasks directly from resreq->minTasks/maxTasks -- * the exact field this feature's apply_job_task_count() call * (core_events.c, immediately before core_build_schedobj()) sets from the * job's real requested count. Two such jobs, ingested fresh into the SAME * core/resmgr (so their resreq_fp's OTHER components -- select_node_id, * rusage_hash -- are guaranteed identical, isolating task count as the * only possible source of a difference), must therefore produce DIFFERENT * cache keys: an unsatisfiable outcome cached for one must not hit for the * other. * * This reuses scheduler-allocation's own alloc_cache test conventions * (test_alloc_cache.c: cache_insert()/cache_lookup() driven directly * against real primary schedobj pointers, a bare xce_scheduler_t with only * unsatisfiable_cache constructed) combined with REAL job ingest via * core_build_job() (this file's own established fresh-ingest technique) * rather than test_alloc_cache.c's own hand-built xce_primary_schedobj_t * fixtures -- this is the genuinely new combination this task adds. * * Empirically verified meaningful (not merely reasoned): with * core_events.c's apply_job_task_count() call temporarily commented out * (the pre-fix always-1/1 behavior bug-1770 describes), both jobs below * collapse to the IDENTICAL resreq_fp (min_tasks=max_tasks=1 for both, * regardless of their different requested counts), and this test's own * cache_lookup() assertion for job B FAILS (a real collision: rc==1, a * false hit) -- confirmed by temporarily commenting out that one line, * rebuilding this binary in isolation, observing the failure, then * reverting the file to a git-clean state before finalizing this test. */ static void test_differing_task_counts_do_not_collide_in_unsatisfiable_cache(void) { xce_scheduler_t sched; xce_queue_t* queue; xce_core_event_t queue_event; xce_job_t* job_a; xce_job_t* job_b; xce_job_t* job_ptrs[2]; xce_core_event_t job_event; xce_sched_job_t* sjob_a; xce_sched_job_t* sjob_b; int out_reason = -1; int rc; memset(&sched, 0, sizeof(sched)); rc = xce_sched_core_init(&(sched.core)); TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "fixture: xce_sched_core_init() must " "succeed"); cache_clear(&sched); TEST_ASSERT_NOT_NULL_MESSAGE( sched.unsatisfiable_cache, "fixture: cache_clear() must construct a real unsatisfiable_cache " "table (mirrors the real per-session construction, main.c's " "xce_core_set_cache_clear_cb() registration)"); 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"); /* Same resreq string/user/queue/project -- differ ONLY in requested * task count. Both ingested into the SAME core/resmgr, so their * resreq_fp's select_node_id/rusage_hash components are guaranteed * identical -- isolating task count as the only possible source of a * fingerprint difference. */ job_a = make_push_job(JOBID_CACHE_A, "rusage[mem=1]", "cache_user", "normal", "cache_proj", 2, 2); job_b = make_push_job(JOBID_CACHE_B, "rusage[mem=1]", "cache_user", "normal", "cache_proj", 3, 3); job_ptrs[0] = job_a; job_ptrs[1] = job_b; memset(&job_event, 0, sizeof(job_event)); job_event.type = XCE_CORE_EVENT_JOBS_PUSH; job_event.u.jobs_push.njobs = 2; job_event.u.jobs_push.jobs = job_ptrs; rc = xce_core_push_events(&(sched.core), &job_event, 1); TEST_ASSERT_TRUE_MESSAGE(rc >= 0, "real JOBS_PUSH ingest must succeed " "for both jobs"); sjob_a = sched_get_job(&(sched.core), JOBID_CACHE_A); sjob_b = sched_get_job(&(sched.core), JOBID_CACHE_B); TEST_ASSERT_NOT_NULL(sjob_a); TEST_ASSERT_NOT_NULL(sjob_b); TEST_ASSERT_NOT_NULL(sjob_a->schedobj); TEST_ASSERT_NOT_NULL(sjob_b->schedobj); TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob_a->schedobj->tag); TEST_ASSERT_EQUAL_INT(XCE_RESREQ_PRIMARY, sjob_b->schedobj->tag); /* Sanity: the two jobs' real, ingest-derived fingerprints DO differ in * exactly (and only) their task-count component -- confirms this * test's premise before exercising the cache itself. */ TEST_ASSERT_EQUAL_INT_MESSAGE( 2, sjob_a->schedobj->u.primary->resreq_fp.min_tasks, "job A's real ingest-derived resreq_fp.min_tasks must reflect its " "own requested task count"); TEST_ASSERT_EQUAL_INT_MESSAGE( 3, sjob_b->schedobj->u.primary->resreq_fp.min_tasks, "job B's real ingest-derived resreq_fp.min_tasks must reflect its " "own (different) requested task count"); TEST_ASSERT_EQUAL_UINT64_MESSAGE( sjob_a->schedobj->u.primary->resreq_fp.rusage_hash, sjob_b->schedobj->u.primary->resreq_fp.rusage_hash, "fixture: both jobs share the identical resreq string, so their " "rusage_hash fingerprint component must be identical -- isolating " "task count as the only differing component"); /* ── The Req 5.4 proof: an unsatisfiable outcome cached for job A * must NOT produce a cache hit for job B. ──────────────────────────── */ rc = cache_insert(&sched, sjob_a->schedobj, 7 /* arbitrary category */); TEST_ASSERT_EQUAL_INT_MESSAGE( 0, rc, "fixture: cache_insert() for job A must succeed"); rc = cache_lookup(&sched, sjob_b->schedobj, &out_reason); TEST_ASSERT_EQUAL_INT_MESSAGE( 0, rc, "Req 5.4: job B (a different requested task count, otherwise " "identical) must NOT hit job A's cached unsatisfiable outcome"); /* Sanity: the cache mechanism itself genuinely works -- job A's OWN * key IS a hit, with the SAME reason category given at insert time. * Without this, the miss above could just as easily mean the cache is * dead altogether, not that it correctly distinguishes task counts. */ rc = cache_lookup(&sched, sjob_a->schedobj, &out_reason); TEST_ASSERT_EQUAL_INT_MESSAGE( 1, rc, "sanity: job A's own key must hit its own cached entry"); TEST_ASSERT_EQUAL_INT(7, out_reason); } 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); RUN_TEST(test_resource_request_update_never_changes_dispatched_task_count); RUN_TEST(test_differing_task_counts_do_not_collide_in_unsatisfiable_cache); return UNITY_END(); }