From 81358b5231c905fe4478e10a63f2b3f245f4a1a8 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 22 Aug 2026 07:53:53 +0000 Subject: [PATCH] feat(scheduler-sim-decision-parity): add reconciliation trigger and lookup (task 2.2) Adds sim_reconcile_unaccounted_jobs(xce_sched_sim_driver_t*, replay_session_t*) to sim_loop.c -- not yet wired into the round boundary (task 3). Once per call, finds every RUN-state job with job->accounts == NULL (the signature of a job already resolved before this replay's own live decision-making ever considered it) and searches this round's decoded recorded-decision stream for a matching DISPATCH entry by jobid. This task implements only the trigger + lookup + not-found path: when no match exists, the job's allocation is left unknown (not fabricated), no error is raised, and the job is safe to re-check next round (Requirement 4.1, 4.2). Match-found reconstruction (ownership transfer, freeing/ compacting the consumed entry, reporting) is tasks 2.3/2.4, not implemented here -- decoded_session's decisions are read but not yet mutated. 6 new tests cover: not-found leaves job unaccounted, cross-round safety, already-accounted jobs are skipped, a same-jobid PEND entry is correctly not mistaken for a DISPATCH match, non-RUN-state jobs are gated out, and NULL-argument safety. RED phase: compile-error-before/link-succeeds-after, the correct RED variant for genuinely new code with no prior behavior. 46/47 scheduler tests pass; the 1 failure is pre-existing bug-1560 (unrelated). Co-Authored-By: Claude Sonnet 5 --- .../scheduler-sim-decision-parity/tasks.md | 2 +- src/scheduler/src/sim_loop.c | 114 +++++ src/scheduler/testsuite/meson.build | 20 + .../test_sim_reconcile_unaccounted.c | 483 ++++++++++++++++++ 4 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/scheduler/testsuite/test_sim_reconcile_unaccounted.c diff --git a/.kiro/specs/scheduler-sim-decision-parity/tasks.md b/.kiro/specs/scheduler-sim-decision-parity/tasks.md index 18848c3d..6b8a39d5 100644 --- a/.kiro/specs/scheduler-sim-decision-parity/tasks.md +++ b/.kiro/specs/scheduler-sim-decision-parity/tasks.md @@ -27,7 +27,7 @@ - _Depends: 1.1_ - _Boundary: Sim Driver Round-Boundary Accounting_ -- [ ] 2.2 Detect and locate already-resolved jobs needing reconciliation +- [x] 2.2 Detect and locate already-resolved jobs needing reconciliation - Identify, once per round, every currently-running job that has no known resource allocation yet (this is the signature of a job that was already resolved earlier in the original recording, before this replay run's own live decision-making ever considered it) - For each such job, search this round's decoded recorded-decision data for the matching originating decision by job identity - When no matching recorded decision is found, leave the job's allocation unknown, report its usage as unavailable rather than fabricating a value, and continue processing the rest of the replay without treating this as an error diff --git a/src/scheduler/src/sim_loop.c b/src/scheduler/src/sim_loop.c index 60c7f54a..315fc1e8 100644 --- a/src/scheduler/src/sim_loop.c +++ b/src/scheduler/src/sim_loop.c @@ -484,6 +484,120 @@ static void* sim_picker_thread_fn(void* arg) return NULL; } +/* + * sim_reconcile_unaccounted_jobs - Recorded-Decision Reconciliation + * (scheduler-sim-decision-parity spec, design.md's "Recorded-Decision + * Reconciliation" component; tasks 2.2-2.4). THIS function, as it stands + * right now, implements only task 2.2's slice: the once-per-round trigger, + * the jobid-based lookup, and the "not found" path (Requirement 4.1, 4.2). + * Tasks 2.3 (allocation reconstruction, ownership transfer, array + * compaction) and 2.4 (feeding the reconstructed decision through the + * decision-reporting channel) build on top of the FOUND branch below, + * deliberately left as a no-op marker for now -- see the comment inside + * that branch. Not yet called from anywhere (task 3 wires this into + * sched_sim_driver_run()'s round-boundary sequence, before + * sched_account_jobs() -- see that call site's own comment above) -- + * standalone and directly unit-testable for now, per design.md's own + * Testing Strategy ("Recorded-Decision Reconciliation leaves a job + * unaccounted, without fabricating data or erroring, when no matching + * recorded decision exists"). + * + * Trigger condition: mirrors sched_account_jobs()'s own `job->accounts == + * NULL` guard (scheduler.c) -- reused here as both the trigger AND (once + * tasks 2.3/2.4 populate job->accounts) the double-application guard, per + * design.md's Implementation Notes. A RUN-state job in `sim->core.jobs` + * with `job->accounts == NULL` is the signature of a job already resolved + * earlier in the original recording, before this replay run's own live + * decision-making ever considered it. + * + * For each such job, searches `decoded_session->decisions` for a matching + * DISPATCH entry by jobid -- a job can only reach RUN-state via a recorded + * DISPATCH decision, so a PEND/PREEMPT entry (even one that happens to + * carry the same jobid) is never treated as a match (see design.md's + * Implementation Notes for why PEND/PREEMPT are structurally impossible to + * legitimately match here). + * + * If no match is found: `job->accounts`/`job->allocs` are left exactly as + * they were (still NULL) -- not an error, not fabricated. Nothing here + * needs to separately record "usage unavailable": leaving `job->accounts == + * NULL` IS the unavailable state -- sched_account_jobs() (scheduler.c) + * already skips exactly this job shape when it re-derives + * resInUse/sharedInUse, so no usage value is ever fabricated for it. The + * same `job->accounts == NULL` trigger naturally re-fires next round with + * no extra bookkeeping required, so calling this again unconditionally on a + * later round is always safe (Requirement 4.1, 4.2). + * + * NULL `sim`/`decoded_session` is a safe no-op, matching this codebase's + * established "never load-bearing" REPL-hook convention (design.md's + * Service Interface preconditions). + */ +static void sim_reconcile_unaccounted_jobs(xce_sched_sim_driver_t* sim, + replay_session_t* decoded_session) +{ + iterator_t it; + int rc; + + if ((sim == NULL) || (decoded_session == NULL)) { + return; + } + + rc = iter_make(&it, sim->core.jobs); + if (rc < 0) { + return; + } + + for (;;) { + xce_sched_job_t* job = NULL; + int i; + + rc = iter_next(&it, (void**)&job); + if ((rc < 0) || (rc == RTTI_ITER_NO_DATA)) { + break; + } + + if ((job == NULL) || !JOB_IS_RUN(job->state) + || (job->accounts != NULL)) { + continue; + } + + for (i = 0; i < decoded_session->n_decisions; i++) { + replay_decision_t* entry = decoded_session->decisions[i]; + + if ((entry == NULL) + || (entry->body_which != ReplayDecision_body_dispatch) + || (entry->body.dispatch == NULL) + || (entry->body.dispatch->alloc == NULL) + || (entry->body.dispatch->alloc->jobid != job->jobid)) { + continue; + } + + /* FOUND -- tasks 2.3/2.4 (NOT this task) reconstruct + * job->allocs/job->accounts from `entry` here (move-then-null- + * then-free ownership transfer, then array compaction) and feed + * the result through the decision-reporting channel. Task 2.2 + * does nothing further for a found match: the job simply + * remains job->accounts == NULL for now, which + * sched_account_jobs() already tolerates safely (same skip path + * as the "not found" case below). */ + break; + } + + if (i == decoded_session->n_decisions) { + char jobidStr[64]; + + /* Requirement 4.1/4.2: no matching recorded decision this round + * -- not an error, nothing fabricated, keep going. */ + fmt_jobid(jobidStr, sizeof(jobidStr), job->jobid); + TRACE("%s no matching recorded DISPATCH decision this round for " + "RUN-state jobid=%s -- accounts left unknown, usage " + "unavailable, will re-check next round", + __func__, jobidStr); + } + } + + iter_destroy(&it); +} + /* * sched_sim_driver_run() -- Req 5 AC 3 (design review follow-up, * 2026-08-19): sched_sim_driver_start() (sim.c) activates tlog virtual- diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index b34b86a7..ae4b6040 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -752,6 +752,23 @@ sched_sim_session_ordering_test = executable( dependencies: [scheduler_test_deps], install: false) +# Recorded-Decision Reconciliation trigger/lookup/not-found contract +# (scheduler-sim-decision-parity spec, task 2.2, Req 4.1/4.2, "Recorded- +# Decision Reconciliation" component). Calls the new sim_loop.c-local +# sim_reconcile_unaccounted_jobs() directly (it is `static` and not yet +# wired into any call site -- task 3's job) against hand-built +# xce_sched_core_t/replay_session_t fixtures (core_build_job()+ +# sched_add_job(), no `.replay` file or capnp round-trip needed). Same +# white-box #include/stub set as sched_sim_event_loop_test -- sim_loop.c's +# compiled TU unconditionally references every one of those externs +# regardless of which functions this file's own tests call. +sched_sim_reconcile_unaccounted_test = executable( + 'sched_sim_reconcile_unaccounted_test', + ['test_sim_reconcile_unaccounted.c'], + include_directories:[incdir], + dependencies: [scheduler_test_deps], + install: false) + # Resource-contention replay fixture (scheduler-sim-decision-parity spec, # task 1.2, Req 2.1/2.2): existing fixtures in this directory never have # scarce capacity, so none can exercise or prove capacity-check correctness. @@ -1102,6 +1119,9 @@ test('scheduler sim equivalence test', test('scheduler sim session ordering test', sched_sim_session_ordering_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) +test('scheduler sim reconcile unaccounted test', + sched_sim_reconcile_unaccounted_test, suite: 'scheduler', + env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler sim resource contention test', sched_sim_resource_contention_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/scheduler/testsuite/test_sim_reconcile_unaccounted.c b/src/scheduler/testsuite/test_sim_reconcile_unaccounted.c new file mode 100644 index 00000000..08dc4d66 --- /dev/null +++ b/src/scheduler/testsuite/test_sim_reconcile_unaccounted.c @@ -0,0 +1,483 @@ +/* + * test_sim_reconcile_unaccounted.c - contract test for + * sim_reconcile_unaccounted_jobs() (sim_loop.c, scheduler-sim-decision- + * parity spec, task 2.2, "Recorded-Decision Reconciliation" component, + * design.md; Requirement 4.1, 4.2). + * + * Scope (task 2.2 only, per design.md's Implementation Notes and this + * task's own text): the once-per-round trigger (RUN-state job with + * job->accounts == NULL), the jobid-based search through this round's + * decoded_session->decisions, and the "not found" path -- leave the job + * unaccounted, report usage unavailable (== job->accounts staying NULL, + * which sched_account_jobs() already tolerates safely), never treat this as + * an error, and stay safe to re-check next round. Allocation reconstruction + * for a FOUND match (ownership transfer, array compaction, decision + * reporting) is deliberately NOT exercised here -- that is tasks 2.3/2.4's + * own test coverage; this file's "found" test (see below) only asserts + * that a match does not corrupt an already-accounted job, not that a match + * gets reconstructed. + * + * sim_reconcile_unaccounted_jobs() is `static` and not yet called from + * anywhere in production code (task 3 wires it into + * sched_sim_driver_run()'s round-boundary sequence) -- called directly here + * instead, standalone, per design.md's own Testing Strategy. + * + * White-box #include set and stub set: IDENTICAL to + * test_sim_event_loop.c's own (same rationale -- sim_loop.c's compiled TU + * unconditionally references every one of these as externs, whether or not + * any single test here calls sched_sim_driver_run() itself). This file + * never calls sched_sim_driver_run()/spawns the picker thread, so (unlike + * test_sim_event_loop.c) no `static` storage workaround is needed for its + * own `xce_sched_sim_driver_t` fixtures. + * + * Fixture technique: xce_sched_core_init() plus core_build_job()/ + * sched_add_job() (core_events.c/core.c, both white-box #included) to place + * a job directly into sim.core.jobs -- same technique + * write_header_and_snapshot_with_pending_job() (test_sim_event_loop.c) + * already establishes for baking a job straight into a core without a real + * `.replay` file. `decoded_session` (replay_session_t) and its + * `decisions[]` (replay_decision_t*) are hand-built directly in memory + * (calloc), not round-tripped through capnp -- sim_reconcile_unaccounted_ + * jobs() only ever reads these plain heap structures, so no encode/decode + * step is needed to exercise it. Fixtures are not torn down (matches this + * whole suite's own "detect_leaks=0" convention, meson.build). + */ +#include +#include +#include +#include +#include +#include + +#include + +#include "controller.capnp.h" +#include "kvmgr.h" +#include "scheduler.h" +#include "xstring.h" + +/* Pull in the real functions under test into this TU (white-box #include, + * same combined set test_sim_event_loop.c's own header comment documents + * and justifies, symbol by symbol). */ +#include "../src/core.c" +#include "../src/core_clock.c" +#include "../src/core_events.c" +#include "../src/core_session.c" +#include "../src/decision.c" +#include "../src/plugin.c" +#include "../src/plugins/builtin_fcfs.c" +#include "../src/recording.c" +#include "../src/recording_session.c" +#include "../src/repl.c" +#include "../src/repl_break.c" +#include "../src/repl_info.c" +#include "../src/repl_params.c" +#include "../src/sim.c" +#include "../src/sim_loop.c" +#include "../src/sim_picker.c" /* bug-1567: sim_loop.c calls sim_bootstrap_picker() (sim_loop.c-cap sibling) as an extern -- undefined reference at link time otherwise */ +#include "../src/sim_record.c" +#include "../src/sim_verify.c" +#include "../src/snapshot.c" +#include "../src/snapshot_load.c" +#include "../src/snapshot_wire.c" + +void kill_sched_job(void* arg) { (void)arg; } +void kill_sched_queue(void* arg) { (void)arg; } +void kill_sched_worker(void* arg) { (void)arg; } +void kill_sched_user(void* arg) { (void)arg; } +void kill_sched_project(void* arg) { (void)arg; } +void kill_sched_resval(xce_val_t* val) { (void)val; } + +/* core_reset_workers()'s (core_session.c) linker dependency -- never + * invoked (no worker is ever added here). */ +void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; } + +/* plugin_prepare_allocation()'s (plugin.c, the "limit" plugin category) + * linker dependency -- never invoked (this file never drives real + * allocation). */ +int sched_fill_limit_requests(xce_scheduler_t* sched, xvec_idval_t* vec, + xlist_t* limits) +{ + (void)sched; + (void)vec; + (void)limits; + return 0; +} + +/* sched_sim_driver_run()'s (sim_loop.c) round-boundary call to the + * relocated sched_account_jobs() (core.h) -- a no-op stand-in here since + * this file does not link scheduler.c/resource.c and never calls + * sched_sim_driver_run() itself; needed purely to satisfy the linker + * (sim_loop.c's compiled TU references it unconditionally). */ +int sched_account_jobs(xce_sched_core_t* core) +{ + (void)core; + return 0; +} + +/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same + * copy every white-box test in this directory that does not link + * schedobj.c already establishes. */ +int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out) +{ + xce_sched_plugin_t* plugin = NULL; + int rc = 0; + + if ((core == NULL) || (out == NULL)) { + return -EINVAL; + } + + *out = NULL; + + if (core->job_pick_plugin == NULL) { + return -EINVAL; + } + + lock(core->plugins, RTTI_LOCK_READ); + rc = key_find(core->plugins, core->job_pick_plugin, (void**)&plugin); + unlock(core->plugins); + + if (rc <= 0) { + return (rc < 0) ? rc : -ENOENT; + } + + if ((plugin == NULL) || (plugin->picker_iface == NULL) + || (plugin->picker_iface->next_job == NULL) + || (plugin->picker_iface->begin_session == NULL) + || (plugin->picker_iface->end_session == NULL) + || (plugin->picker_iface->on_dispatch == NULL) + || (plugin->picker_iface->on_pend == NULL)) { + return -EINVAL; + } + + *out = plugin; + + return 0; +} + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* ---------------------------------------------------------------------- */ +/* Fixture helpers */ +/* ---------------------------------------------------------------------- */ + +static void fixture_init(xce_sched_sim_driver_t* sim) +{ + int rc; + + memset(sim, 0, sizeof(*sim)); + rc = xce_sched_core_init(&(sim->core)); + TEST_ASSERT_EQUAL_INT(0, rc); +} + +/* Bakes one job directly into `core->jobs` (no `.replay` file, no capnp -- + * same core_build_job()+sched_add_job() technique test_sim_event_loop.c's + * own write_header_and_snapshot_with_pending_job() establishes). Passing a + * non-NULL `allocs` reproduces the "already accounted" job shape (mirrors + * core_build_job()'s own `if (j->allocs != NULL) { ...; job->accounts = + * make_job_account(job->allocs); }` branch, core_events.c) -- pass NULL for + * the ordinary "unaccounted RUN-state job" shape this file's central tests + * target. */ +static xce_sched_job_t* add_job(xce_sched_core_t* core, uint64_t jobid, + unsigned int state, xce_job_alloc_t* allocs) +{ + xce_queue_t wire_queue; + xce_sched_queue_t* q; + job_submit_data_t submit; + xce_job_t wire_job; + xce_sched_job_t* job; + + memset(&wire_queue, 0, sizeof(wire_queue)); + wire_queue.queue = (char*)"normal"; + wire_queue.status = QUEUE_STAT_OPEN; + wire_queue.priority = 1; + q = core_build_queue(&wire_queue); + TEST_ASSERT_NOT_NULL(q); + TEST_ASSERT_GREATER_OR_EQUAL(0, sched_add_queue(core, q)); + + memset(&submit, 0, sizeof(submit)); + submit.queue = (char*)"normal"; + submit.user = (char*)"alice"; + submit.mintasks = 1; + submit.maxtasks = 1; + + memset(&wire_job, 0, sizeof(wire_job)); + wire_job.jobid = jobid; + wire_job.state = state; + wire_job.submit = &submit; + wire_job.allocs = allocs; + + job = core_build_job(core, &wire_job); + TEST_ASSERT_NOT_NULL(job); + TEST_ASSERT_GREATER_OR_EQUAL(0, sched_add_job(core, job)); + + return job; +} + +static replay_decision_t* make_dispatch_entry(uint64_t jobid) +{ + xce_job_alloc_t* alloc = make_job_alloc(jobid, 0, NULL); + sched_dispatch_decision_t* dispatch + = (sched_dispatch_decision_t*)calloc(1, sizeof(*dispatch)); + replay_decision_t* rd = (replay_decision_t*)calloc(1, sizeof(*rd)); + + dispatch->alloc = alloc; + + rd->body_which = ReplayDecision_body_dispatch; + rd->body.dispatch = dispatch; + + return rd; +} + +static replay_decision_t* make_pend_entry(uint64_t jobid) +{ + sched_pend_decision_t* pend + = (sched_pend_decision_t*)calloc(1, sizeof(*pend)); + replay_decision_t* rd = (replay_decision_t*)calloc(1, sizeof(*rd)); + + pend->jobid = jobid; + pend->reason = NULL; + + rd->body_which = ReplayDecision_body_pend; + rd->body.pend = pend; + + return rd; +} + +static replay_session_t* make_decoded_session(uint64_t begin_round, + replay_decision_t* entry) +{ + replay_session_t* s = (replay_session_t*)calloc(1, sizeof(*s)); + + s->begin_round = begin_round; + s->summary = NULL; + + if (entry == NULL) { + s->decisions = NULL; + s->n_decisions = 0; + return s; + } + + s->decisions = (replay_decision_t**)calloc(1, sizeof(*(s->decisions))); + s->decisions[0] = entry; + s->n_decisions = 1; + + return s; +} + +/* ---------------------------------------------------------------------- */ +/* Test 1 (the task's own primary Observable): a RUN-state job with no */ +/* known allocation and no matching recorded decision in this round's */ +/* window is left unaccounted (usage unavailable), and the call returns */ +/* normally -- no error, nothing fabricated (Requirement 4.1, 4.2). */ +/* ---------------------------------------------------------------------- */ + +static void +test_reconcile_leaves_run_job_unaccounted_when_no_recorded_decision(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_job_t* job; + replay_session_t* decoded; + + fixture_init(&sim); + + /* RUN-state, no known allocation -- e.g. a job dispatched before the + * snapshot this (hypothetical) replay started from, exactly the + * scenario this task's own Observable Completion text describes. */ + job = add_job(&(sim.core), 7001ULL, JOB_STAT_RUN, NULL); + TEST_ASSERT_NULL(job->accounts); + TEST_ASSERT_NULL(job->allocs); + + /* This round's decoded recorded-decision data has nothing for this + * jobid at all -- the replay's own window never captured it. */ + decoded = make_decoded_session(1000ULL, NULL); + + sim_reconcile_unaccounted_jobs(&sim, decoded); + + TEST_ASSERT_NULL_MESSAGE( + job->accounts, + "no matching recorded decision -- job->accounts must stay NULL " + "(usage unavailable), never fabricated"); + TEST_ASSERT_NULL_MESSAGE(job->allocs, "job->allocs must likewise stay " + "NULL -- nothing to reconstruct " + "from"); + + printf("test_reconcile_leaves_run_job_unaccounted_when_no_recorded_" + "decision: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 2: a job left unresolved this round is safe to re-check again on */ +/* a later round, with no adverse effect (task's own explicit text). */ +/* ---------------------------------------------------------------------- */ + +static void test_reconcile_is_safe_to_recheck_on_a_later_round(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_job_t* job; + replay_session_t* decoded_round1; + replay_session_t* decoded_round2; + + fixture_init(&sim); + + job = add_job(&(sim.core), 7002ULL, JOB_STAT_RUN, NULL); + + decoded_round1 = make_decoded_session(1000ULL, NULL); + sim_reconcile_unaccounted_jobs(&sim, decoded_round1); + TEST_ASSERT_NULL(job->accounts); + + /* Round 2: still no matching recorded decision -- calling again must + * not error, crash, or otherwise behave differently just because this + * is a repeat check of the same job. */ + decoded_round2 = make_decoded_session(2000ULL, NULL); + sim_reconcile_unaccounted_jobs(&sim, decoded_round2); + + TEST_ASSERT_NULL_MESSAGE( + job->accounts, + "re-checking an unresolved job on a later round must remain safe " + "and leave it unaccounted, with no adverse effect"); + + printf( + "test_reconcile_is_safe_to_recheck_on_a_later_round: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 3: a job that already has known accounts (either reconciled */ +/* earlier or freshly dispatched by the live path) is never re-touched -- */ +/* the trigger condition (job->accounts == NULL) must gate correctly even */ +/* when a matching recorded DISPATCH entry for the same jobid IS present */ +/* this round (task 2.2 does not implement match-handling yet, but the */ +/* trigger itself must still never fire for an already-accounted job). */ +/* ---------------------------------------------------------------------- */ + +static void test_reconcile_skips_job_that_already_has_accounts(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_job_t* job; + xce_job_account_t* accounts_before; + replay_session_t* decoded; + + fixture_init(&sim); + + job = add_job(&(sim.core), 7003ULL, JOB_STAT_RUN, + make_job_alloc(7003ULL, 0, NULL)); + TEST_ASSERT_NOT_NULL_MESSAGE( + job->accounts, "fixture setup: job must already have accounts"); + accounts_before = job->accounts; + + decoded = make_decoded_session(1000ULL, make_dispatch_entry(7003ULL)); + + sim_reconcile_unaccounted_jobs(&sim, decoded); + + TEST_ASSERT_EQUAL_PTR_MESSAGE( + accounts_before, job->accounts, + "an already-accounted job must never be re-touched, even when a " + "matching recorded decision exists this round (Requirement 3.3's " + "double-application guard)"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, decoded->n_decisions, + "task 2.2 does not consume/compact recorded entries -- that is " + "task 2.3's job"); + + printf("test_reconcile_skips_job_that_already_has_accounts: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 4: a recorded PEND entry sharing the same jobid must never be */ +/* mistaken for a match -- only a DISPATCH entry can legitimately account */ +/* for a RUN-state job (design.md's Implementation Notes). */ +/* ---------------------------------------------------------------------- */ + +static void test_reconcile_ignores_non_dispatch_entry_with_matching_jobid( + void) +{ + xce_sched_sim_driver_t sim; + xce_sched_job_t* job; + replay_session_t* decoded; + + fixture_init(&sim); + + job = add_job(&(sim.core), 7004ULL, JOB_STAT_RUN, NULL); + + decoded = make_decoded_session(1000ULL, make_pend_entry(7004ULL)); + + sim_reconcile_unaccounted_jobs(&sim, decoded); + + TEST_ASSERT_NULL_MESSAGE( + job->accounts, + "a recorded PEND entry must never be treated as a DISPATCH match, " + "even when its jobid matches -- job stays unaccounted"); + + printf("test_reconcile_ignores_non_dispatch_entry_with_matching_jobid:" + " PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 5: the trigger only fires for a RUN-state job -- a PEND-state job */ +/* with no accounts must never be touched here (the live picker re-derives */ +/* PEND decisions fresh every round, per design.md's Revalidation */ +/* Triggers section). */ +/* ---------------------------------------------------------------------- */ + +static void test_reconcile_skips_non_run_state_job(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_job_t* job; + replay_session_t* decoded; + + fixture_init(&sim); + + job = add_job(&(sim.core), 7005ULL, JOB_STAT_PEND, NULL); + TEST_ASSERT_NULL(job->accounts); + + /* A matching recorded DISPATCH entry is present -- but must not be + * consulted at all, since the trigger's RUN-state gate must reject this + * job before any lookup happens. */ + decoded = make_decoded_session(1000ULL, make_dispatch_entry(7005ULL)); + + sim_reconcile_unaccounted_jobs(&sim, decoded); + + TEST_ASSERT_NULL_MESSAGE( + job->accounts, + "a non-RUN-state job must never be reconciled here, regardless of " + "what recorded data exists for it this round"); + + printf("test_reconcile_skips_non_run_state_job: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test 6: NULL `sim`/`decoded_session` is a safe no-op (design.md's */ +/* Service Interface preconditions -- "never load-bearing" convention). */ +/* ---------------------------------------------------------------------- */ + +static void test_reconcile_null_args_are_a_safe_noop(void) +{ + xce_sched_sim_driver_t sim; + replay_session_t* decoded; + + fixture_init(&sim); + decoded = make_decoded_session(1000ULL, NULL); + + sim_reconcile_unaccounted_jobs(NULL, decoded); + sim_reconcile_unaccounted_jobs(&sim, NULL); + sim_reconcile_unaccounted_jobs(NULL, NULL); + + /* Reaching this line without a crash IS the assertion. */ + TEST_ASSERT_TRUE(1); + + printf("test_reconcile_null_args_are_a_safe_noop: PASS\n"); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST( + test_reconcile_leaves_run_job_unaccounted_when_no_recorded_decision); + RUN_TEST(test_reconcile_is_safe_to_recheck_on_a_later_round); + RUN_TEST(test_reconcile_skips_job_that_already_has_accounts); + RUN_TEST(test_reconcile_ignores_non_dispatch_entry_with_matching_jobid); + RUN_TEST(test_reconcile_skips_non_run_state_job); + RUN_TEST(test_reconcile_null_args_are_a_safe_noop); + return UNITY_END(); +}