From 5e7a4aa392db582370a3ee485089ab75f1d1f610 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Wed, 15 Jul 2026 08:03:04 +0000 Subject: [PATCH] feat(job-array-single-wal-record): build the compact scheduler push Adds xcesched_push_jobarray() (sched.c), encoding one compact (base jobid, array_size, one spec) Data.jobArray descriptor -- reusing the JobSubmit capnp struct already added to the Data union in task 1.2 -- instead of N individually-encoded job structs. sched_job_new_fun() branches on l->count > 1 to call it; the existing single-job path (l->count == 1 -> xcesched_push_joblist()) is byte-for-byte unchanged. Sends via direct xce_request()/OPCode_push/Request_body_pushmsg rather than the existing xce_push_data_req() helper, which hard-rejects any payload outside jobs/workers/resources/resvals and cannot carry a single non-list descriptor. design.md corrected to record this transport deviation for task 5.2 to follow. New standalone sched_push_unit_test binary (registered in meson.build): sched.c/dataex.c/egress.c were never linked into controller_unit_test, and two existing test files there ship local fakes of on_scheduler_join()/notify()/collect_confvars() that would collide with the real definitions at link time. Task 5.1 of job-array-single-wal-record (Requirements 6.1, 6.3, 6.4). --- .../job-array-single-wal-record/design.md | 6 +- .../job-array-single-wal-record/tasks.md | 3 +- src/controller/meson.build | 31 ++ src/controller/sched.c | 68 ++++- .../testsuite/test_job_array_sched_push.c | 282 ++++++++++++++++++ 5 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 src/controller/testsuite/test_job_array_sched_push.c diff --git a/.kiro/specs/job-array-single-wal-record/design.md b/.kiro/specs/job-array-single-wal-record/design.md index be715c2..a4939fe 100644 --- a/.kiro/specs/job-array-single-wal-record/design.md +++ b/.kiro/specs/job-array-single-wal-record/design.md @@ -388,7 +388,7 @@ Both reuses are deliberate (Requirement 6's own wire-shape note: "the same compa **Dependencies** - Inbound: `HOOK_DETAIL_JOB_NEW` fire from `apply_committed_entry()` (Criticality P0) -- Outbound: `xce_push_data_req()` (unchanged transport, Criticality P0) +- Outbound scheduler push: direct `xce_request()`/`OPCode_push`/`Request_body_pushmsg` (Criticality P0). **Correction (found during task 5.1 review, 2026-07-15):** `xce_push_data_req()`'s dispatch switch has explicit cases only for jobs/workers/resources/resvals and hard-rejects (`-EINVAL`) anything else via its `default` case — it cannot carry a single non-list `Data.jobArray` descriptor, only chunk LIST-shaped payloads. `xcesched_push_jobarray()` therefore sends directly via `xce_request()`, mirroring `xce_push_req()`'s existing shape for a single non-list `xce_data_t` payload. This is a faithful, wire-compatible substitute but does NOT go through `xce_push_data_req()`'s `checkf`-gated `check_controller`/`c->running` liveness check that every other push path gets — a narrow, low-severity shutdown-race behavioral difference from sibling pushes, not a new safety hazard. Task 5.2's API-server push faces the identical fork and should follow the same direct-`xce_request()` pattern rather than re-deriving it. **Contracts**: Event [x] / API [x] @@ -400,8 +400,8 @@ Both reuses are deliberate (Requirement 6's own wire-shape note: "the same compa ##### API Contract | Direction | Message | Payload | Notes | |---|---|---|---| -| Controller → Scheduler | `Data.jobArray` push | `JobSubmit` (base jobid, array_size, one spec) | New variant; existing `xce_push_data_req` transport | -| Controller → API server | `Data.jobArray` push | `JobSubmit` (base jobid, array_size, one spec) | Same variant, same transport, per connected API server (mirrors existing fan-out) | +| Controller → Scheduler | `Data.jobArray` push | `JobSubmit` (base jobid, array_size, one spec) | New variant; sent via direct `xce_request()` (not `xce_push_data_req()` — see Dependencies note above) | +| Controller → API server | `Data.jobArray` push | `JobSubmit` (base jobid, array_size, one spec) | Same variant, same direct-`xce_request()` transport, per connected API server (mirrors existing fan-out) | **Implementation Notes** - Integration: no new opcode — `Data`'s existing union discriminator (`table`) already routes to the new variant on both the scheduler and the API-server receive paths. diff --git a/.kiro/specs/job-array-single-wal-record/tasks.md b/.kiro/specs/job-array-single-wal-record/tasks.md index e085ce8..6fe6a14 100644 --- a/.kiro/specs/job-array-single-wal-record/tasks.md +++ b/.kiro/specs/job-array-single-wal-record/tasks.md @@ -109,7 +109,7 @@ - _Depends: 3.1_ - [ ] 5. Compact Scheduler/API-Server Push -- [ ] 5.1 (P) Build the compact scheduler push +- [x] 5.1 (P) Build the compact scheduler push - Add a function that encodes the compact (base jobid, array size, one spec) message for the scheduler and branch the existing job-created push handler to call it, instead of the existing per-job push, whenever the notification carries more than one element - Leave the single-element (ordinary single-job) branch completely unmodified - Observable: a synthetic multi-element notification produces exactly one compact push call, not N individually-encoded job structs; a synthetic single-element notification is unaffected @@ -178,3 +178,4 @@ **UPDATE (post-4.4): this gap was NOT closed by Phase 4.** Tasks 4.1-4.4 correctly deliver the generic negative-rc halt/fence/gate/self-heal machinery — a bulk-insert-phase failure DOES still engage the fence and block promotion, same as any other failure — but none of them add the "no partial/divergent set" guarantee specifically for this seam, because that guarantee is structurally false on this path with current APIs (no `key_del`). Task 4.4's own capstone test deliberately used the (fully atomic) construction-phase seam instead, precisely because asserting "zero partial elements" against the bulk-insert seam would be dishonest. **This residual gap remains open and unowned by any task in this spec's current scope** — flag at `/kiro-validate-impl` time; closing it would require either a `key_del`/rollback primitive in `kv.c`/`kvmgr.c`, or a re-derive-and-re-notify recovery path for the orphaned prefix, either of which is design-level work beyond this spec's approved task list. - **Task 4.1**: `persist_apply_pump_run()` now halts the walk (no `last_applied_lsn` advance) on ANY negative return from `apply_committed_entry()`, not just the new array type — this is a general fix. Investigated whether this newly exposes a hazard for the pre-existing job-state fact entries (`WAL_JOB_START/FINISH/FAIL/UPDATE`), which have their own real `-ENOENT` branch when the target jobid isn't in `c->jobmgr`: confirmed NOT a hazard, because `c->jobmgr` (`xkvmgr_type`) has no `key_del` (jobs are never removed for the process lifetime, corroborated by `job.c:428,455,645`), and this task's own strict sequential-no-skip apply means a fact's LSN is only reached after its causally-prior submit already applied. So a live `-ENOENT` on a fact can now only mean genuine corruption/divergence — halting is correct, not a new footgun. Independent reviewer confirmed this via `test_term_safety_adapter.c:2039-2043`'s synthetic (non-production-representative) trigger. design.md's "Existing Architecture Analysis" table row overclaims ("every existing WAL type... returns 0 or positive in practice") — technically inaccurate re: the fact-ENOENT branch — left as-is since the persist.c code comment at the site already documents the exception correctly; low-severity, non-blocking. - **Task 4.2**: New `_Atomic uint64_t apply_resource_fenced_lsn` field on `persist_t` (0 = clear, nonzero = the stuck LSN) plus `persist_apply_resource_fenced()`/`persist_mark_apply_resource_fenced()`/`persist_clear_apply_resource_fence()`, wired into `persist_apply_pump_run()`'s existing halt branch (engage) and unconditionally after every successful advance (clear). The unconditional clear is provably equivalent to "clear only on the fenced LSN's own successful retry" because `last_applied_lsn` has exactly 3 writers codebase-wide, the other two (`replay_wal`, `persist_recover`) are boot-only and temporally disjoint from the live pump, and a failed walk never advances past the stuck LSN — so the next success by construction IS that LSN's retry. **Known residual gap (out of this task's boundary, flagged by review, NOT yet fixed)**: `replay_wal()` (boot/restart replay, persist.c:3875-3888) still discards `apply_committed_entry()`'s failure return the same way the live pump did before task 4.1, and `persist_init()` unconditionally zeroes the fence field on every restart. A node that crashes while fenced and re-hits the identical resource-exhaustion condition during boot replay will silently pass it and look fully promotable while actually divergent — task 4.1/4.2's protection does not yet extend to the boot-replay path. This is a pre-existing gap (not introduced by this feature) but is now inconsistent with the live path's new guarantee; worth a dedicated follow-up task before anything downstream places full trust in this fence across a restart. +- **Task 5.1**: New `xcesched_push_jobarray()` (`sched.c`) sends via direct `xce_request()`/`OPCode_push`/`Request_body_pushmsg` rather than the existing `xce_push_data_req()` helper — `xce_push_data_req()`'s dispatch switch hard-rejects (`-EINVAL`) anything outside jobs/workers/resources/resvals, so it cannot carry a single non-list `Data.jobArray` descriptor. design.md corrected to match (Dependencies + API Contract for the Compact Scheduler/API-Server Push component). Task 5.2's API-server push will hit the identical fork and should follow the same direct-`xce_request()` pattern rather than re-deriving it. One accepted, low-severity trade-off: the direct path does not go through `xce_push_data_req()`'s `checkf`-gated `check_controller`/`c->running` liveness check that every other push gets — a narrow shutdown-race behavioral difference from sibling pushes, not a new safety hazard; not fixed here since threading a liveness check into a single new call site duplicates rather than reuses the shared gate. `sched.c`/`dataex.c`/`egress.c` were never linked into `controller_unit_test` (two existing test files there ship their own fake `on_scheduler_join()`/`notify()`/`collect_confvars()` precisely because of this, and linking the real ones would collide) — task 5.1's tests live in a new standalone `sched_push_unit_test` binary instead; task 5.2 will likely face the same fork for `apiserver.c` and should check for an analogous existing-fakes collision before choosing a test-binary strategy. diff --git a/src/controller/meson.build b/src/controller/meson.build index 64e5780..1d88fb3 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -210,3 +210,34 @@ test('controller test', controller_unit_test, timeout: 300, env: ['ASAN_OPTIONS=detect_leaks=0']) +# job-array-single-wal-record task 5.1: sched.c's push logic (sched_job_new_fun / +# xcesched_push_jobarray) is exercised by a small, STANDALONE test binary rather +# than being folded into controller_unit_test_src above. sched.c/dataex.c are not +# part of that binary today -- several existing files there (test_commit_barrier_ +# reply.c, test_worker_join.c) already provide their OWN local fake definitions +# of on_scheduler_join()/notify()/collect_confvars(), precisely because the real +# sched.c/dataex.c were never linked in. Linking the real ones alongside those +# fakes collides at link time (multiple definition). This binary links the real +# sched.c + its real dataex.c/egress.c dependencies, isolated from that +# collision; only opget.c's one-line check_worker_() is stubbed locally in the +# test file (linking all of opget.c would pull in its own further dependencies +# this test does not need). +sched_push_unit_test_src = [ + 'testsuite/test_job_array_sched_push.c', + 'sched.c', + 'dataex.c', + 'egress.c', +] + +sched_push_unit_test = executable('sched_push_unit_test', + sched_push_unit_test_src, + include_directories: [incdir], + dependencies: controller_unit_test_deps, + c_args: ['-DXCE_TESTING'], + install: false) + +test('sched push test', sched_push_unit_test, + suite: 'controller', + timeout: 60, + env: ['ASAN_OPTIONS=detect_leaks=0']) + diff --git a/src/controller/sched.c b/src/controller/sched.c index d26ced0..e2ac957 100644 --- a/src/controller/sched.c +++ b/src/controller/sched.c @@ -11,6 +11,62 @@ extern void collect_shared_resvals(cluster_t*, collect_check_fn_t, xlist_t*); +/* job-array-single-wal-record task 5.1 (design.md "Compact Scheduler/ + * API-Server Push", File Structure Plan sched.c bullet; Req 6.1, 6.3, 6.4): + * encodes ONE compact (base jobid, array size, one shared spec) Data.jobArray + * push -- reusing the JobSubmit capnp struct (already added to the Data + * union in task 1.2, controller.capnp), never N individually-encoded Job + * structs. (base_jobid, array_size) are derived from the already-constructed + * xlist_t the HOOK_DETAIL_JOB_NEW fire carries (apply_committed_entry's + * WAL_JOB_ARRAY_SUBMIT case, task 3.2): the first element's jobid is the + * array base and l->count is the array size; every element shares the same + * submit spec by construction (restore_job_array's own construction + * discipline, persist.c), so any one element's submit pointer is the one + * shared spec to encode. + * + * Sent via xce_request() directly (OPCode_push / Request_body_pushmsg) + * rather than xce_push_data_req(): that helper's pagination loop exists to + * chunk large LIST payloads (jobs/workers) across multiple packets and has + * no case for this single, non-chunkable descriptor -- adding one would mean + * editing shared transport code outside this task's sched.c-only boundary + * (task 5.2's apiserver push is a separate, possibly-concurrent task that + * would collide on the same edit). The wire mechanism itself (OPCode_push, + * Request_body_pushmsg, the xce_data_t/Data union envelope) is unchanged -- + * this mirrors xce_push_req()'s (request.c, static) exact shape for a single + * non-list xce_data_t payload. */ +static int xcesched_push_jobarray(cluster_t* c, xlist_t* l) +{ + xce_job_t* first; + xce_job_submit_t job_array; + xce_data_t data; + xce_request_t request; + + if ((c == NULL) || (l == NULL) || (l->count <= 0)) { + return -EINVAL; + } + + first = (xce_job_t*)xlist_get(l, 0); + if ((first == NULL) || (first->submit == NULL)) { + return -EINVAL; + } + + memset(&job_array, 0x0, sizeof(job_array)); + job_array.jobid = first->jobid; + job_array.array_size = (uint64_t)l->count; + job_array.submit = first->submit; + + memset(&data, 0x0, sizeof(data)); + data.table = Data_data_jobArray; + data.data.job_array = &job_array; + + memset(&request, 0x0, sizeof(request)); + request.opcode = OPCode_push; + request.body_which = Request_body_pushmsg; + request.body.pushmsg = &data; + + return xce_request(c->sched.ep, &request); +} + static int xcesched_push_joblist(cluster_t* c, xlist_t* l) { xce_data_t data; @@ -280,7 +336,17 @@ static int sched_job_new_fun(int type, int situation, void* data, va_list ap) case HOOK_AFTER_EVENT: rc = -EINVAL; if (l != NULL) { - rc = xcesched_push_joblist(c, l); + /* job-array-single-wal-record task 5.1 (Req 6.1, 6.3, 6.4): + * apply_committed_entry()'s WAL_JOB_ARRAY_SUBMIT case is the + * ONLY way this hook is ever fired with more than one + * element (Req 3.4 — one HOOK_DETAIL_JOB_NEW fire per + * committed array record, carrying every constructed + * element). Route that case through the compact descriptor + * push instead of the per-job push. The ordinary + * single-job branch (l->count == 1) below is the exact, + * unmodified call this function always made (Req 8.1). */ + rc = (l->count > 1) ? xcesched_push_jobarray(c, l) + : xcesched_push_joblist(c, l); } break; default: diff --git a/src/controller/testsuite/test_job_array_sched_push.c b/src/controller/testsuite/test_job_array_sched_push.c new file mode 100644 index 0000000..1663e9d --- /dev/null +++ b/src/controller/testsuite/test_job_array_sched_push.c @@ -0,0 +1,282 @@ +/* test_job_array_sched_push.c — job-array-single-wal-record task 5.1 + * + * Unit tests for the Compact Scheduler/API-Server Push component's + * controller-side scheduler push (design.md "Compact Scheduler/API-Server + * Push"; Req 6.1, 6.3, 6.4): + * + * - A synthetic multi-element HOOK_DETAIL_JOB_NEW fire (the exact shape + * apply_committed_entry()'s WAL_JOB_ARRAY_SUBMIT case produces, task 3.2 — + * one AFTER_EVENT fire, one xlist_t carrying every constructed element) + * produces EXACTLY ONE queued push packet, encoding the compact + * Data.jobArray descriptor (base jobid + array size + one shared spec) — + * never N individually-encoded Job/SchedJob structs. + * - A synthetic single-element fire (ordinary single-job submission) is + * byte-for-byte unaffected: still the existing Data.schedjobs push via + * xcesched_push_joblist(), unchanged (Req 8.1). + * + * Fixture: sched_job_new_fun()/xcesched_push_joblist()/xcesched_push_jobarray() + * are static to sched.c, so this file drives them through the same public + * entry points production code uses: on_scheduler_join() installs the hook + * (its `hook_installed` static is once-per-process, so ALL tests in this + * file share ONE cluster_t/hooksys wired exactly once — see jsp_setup) and + * xce_run_hooks() fires it exactly as apply_committed_entry() does. The + * outbound push itself is observed the same way test_commit_barrier_rollout.c + * observes queued replies (cb5_scan_replies): a zeroed, fd=-1 stack + * endpoint_t queues packets on ep->outq without touching a real socket + * (endpoint_update_epoll no-ops, zeroed pthread mutexes are valid); this + * test drains and raw-capnp-decodes those packets instead. + * + * Job list elements are plain job_t structs with only the two fields either + * push function reads (.job.jobid, .job.submit) plus submit_lsn = 0 (makes + * job_submit_committed() unconditionally true, matching a freshly-committed + * array's elements without needing a real persist_t) — NOT routed through + * create_job()/jobj.c, whose N-way construction is already covered by + * test_job_array_apply.c. + */ + +#include +#include +#include +#include +#include + +#include + +#include "cluster.h" +#include "controller.capnp.h" +#include "hook.h" +#include "netio.h" +#include "request.h" +#include "xce/cluster_def.h" +#include "xce/types.h" +#include "xlist.h" + +extern int on_scheduler_join(cluster_t*); + +/* This file builds a STANDALONE test binary (meson.build's sched_push_unit_ + * test) linking the real sched.c + its real dataex.c/egress.c dependencies — + * see meson.build's comment for why this can't be folded into the shared + * controller_unit_test binary. opget.c's check_worker_() is sched.c's only + * remaining unresolved reference (from xcesched_push_workers(), reached only + * through on_scheduler_join()'s push-on-join branch, which this file's setup + * deliberately never triggers at runtime — c->sched.ep is NULL during that + * call — but the symbol must still resolve at link time). Stubbed locally + * rather than linking all of opget.c, mirroring opget.c's own trivial body. */ +int check_worker_(xce_worker_t* w) { return (w != NULL) ? 1 : 0; } + +/* ------------------------------------------------------------------ */ +/* Fixture */ +/* ------------------------------------------------------------------ */ + +static cluster_t g_jsp_c; +static endpoint_t g_jsp_ep; +static int g_jsp_hook_wired; + +static void jsp_drain_ep(endpoint_t* ep) +{ + data_item_t* it; + + while ((it = endpoint_deq(ep)) != NULL) { + io_packet_t* pkt = ITPTR_TO_PACKET(it); + + free_packet(pkt); + free(pkt); + } +} + +static void jsp_setup(void) +{ + if (!g_jsp_hook_wired) { + memset(&g_jsp_c, 0, sizeof(g_jsp_c)); + TEST_ASSERT_EQUAL_INT( + 0, xce_hook_init(&g_jsp_c.hooksys, XCE_CONTROLLER_HOOK_MAX)); + /* sched.ep == NULL here so on_scheduler_join's push-on-join branch + * (xcesched_push_configs/workers/jobs) is a no-op — this call's only + * effect is installing sched_job_hook_fun (and siblings) into + * g_jsp_c.hooksys, once, process-wide (hook_installed is a static + * inside on_scheduler_join). */ + g_jsp_c.sched.ep = NULL; + TEST_ASSERT_EQUAL_INT(0, on_scheduler_join(&g_jsp_c)); + g_jsp_hook_wired = 1; + } + + memset(&g_jsp_ep, 0, sizeof(g_jsp_ep)); + g_jsp_ep.fd = -1; + + g_jsp_c.sched.ep = &g_jsp_ep; + g_jsp_c.running = 1; + g_jsp_c.myhost = &g_jsp_c.myhost_info; + g_jsp_c.myhost_info.name = (char*)"ctrl-jsp"; + g_jsp_c.myhost_info.key = (char*)""; +} + +static void jsp_teardown(void) +{ + jsp_drain_ep(&g_jsp_ep); + g_jsp_c.sched.ep = NULL; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static job_submit_data_t g_jsp_submit_data; + +static void jsp_make_job(job_t* out, uint64_t jobid) +{ + memset(out, 0, sizeof(*out)); + out->job.jobid = jobid; + out->job.submit = &g_jsp_submit_data; + out->submit_lsn + = 0; /* job_submit_committed(): submit_lsn==0 => always exportable */ +} + +static int jsp_fire_job_new(xlist_t* l) +{ + return xce_run_hooks(&g_jsp_c.hooksys, XCE_CONTROLLER_HOOK_JOB, + HOOK_AFTER_EVENT, HOOK_DETAIL_JOB_NEW, &g_jsp_c, l); +} + +typedef struct { + int n_pushes; + int last_data_which; + uint64_t last_jobid; + uint64_t last_array_size; +} jsp_push_obs_t; + +/* Raw-capnp-parse every queued push packet on ep->outq (mirrors + * test_commit_barrier_rollout.c's cb5_scan_replies). Drains the queue. */ +static void jsp_scan_pushes(endpoint_t* ep, jsp_push_obs_t* obs) +{ + data_item_t* it; + + memset(obs, 0, sizeof(*obs)); + while ((it = endpoint_deq(ep)) != NULL) { + io_packet_t* pkt = ITPTR_TO_PACKET(it); + struct capn c; + struct Request rq; + Request_ptr rp; + + capn_init_mem(&c, (const uint8_t*)pkt->data, pkt->sz, 0); + rp.p = capn_getp(capn_root(&c), 0, 1); + read_Request(&rq, rp); + if (rq.body_which == Request_body_pushmsg) { + struct Data d; + + read_Data(&d, rq.body.pushmsg); + obs->n_pushes += 1; + obs->last_data_which = (int)d.data_which; + if (d.data_which == Data_data_jobArray) { + struct JobSubmit js; + + read_JobSubmit(&js, d.data.jobArray); + obs->last_jobid = js.jobid; + obs->last_array_size = js.arraySize; + } + } + capn_free(&c); + free_packet(pkt); + free(pkt); + } +} + +/* ------------------------------------------------------------------ */ +/* Test 1: multi-element fire -> exactly one compact Data.jobArray push */ +/* ------------------------------------------------------------------ */ + +static void test_multi_element_job_new_produces_one_compact_jobarray_push(void) +{ + job_t jobs[5]; + void* storage[5]; + xlist_t l = { .count = 5, .caps = 5, .delta = 5, .data = storage }; + jsp_push_obs_t obs; + int rc; + + jsp_setup(); + + for (int i = 0; i < 5; i++) { + jsp_make_job(&jobs[i], 5000 + (uint64_t)i); + storage[i] = &jobs[i].job; + } + + rc = jsp_fire_job_new(&l); + TEST_ASSERT_EQUAL_INT(0, rc); + + jsp_scan_pushes(&g_jsp_ep, &obs); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, obs.n_pushes, + "a 5-element array notification must produce exactly ONE push packet, " + "not one per element (Req 6.1, 6.4)"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + Data_data_jobArray, obs.last_data_which, + "the push must encode the compact Data.jobArray descriptor, not " + "Data.schedjobs/Data.jobs (N individually-encoded job structs)"); + TEST_ASSERT_EQUAL_UINT64_MESSAGE( + 5000, obs.last_jobid, + "base jobid must be the array's first (lowest) jobid"); + TEST_ASSERT_EQUAL_UINT64_MESSAGE( + 5, obs.last_array_size, + "arraySize must be the notification's element count"); + + jsp_teardown(); + printf("test_multi_element_job_new_produces_one_compact_jobarray_push: " + "PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Test 2: single-element fire -> unmodified per-job Data.schedjobs push */ +/* ------------------------------------------------------------------ */ + +static void test_single_element_job_new_is_unaffected(void) +{ + job_t job; + void* storage[1]; + xlist_t l = { .count = 1, .caps = 1, .delta = 1, .data = storage }; + jsp_push_obs_t obs; + int rc; + + jsp_setup(); + + jsp_make_job(&job, 9001); + storage[0] = &job.job; + + rc = jsp_fire_job_new(&l); + TEST_ASSERT_EQUAL_INT(0, rc); + + jsp_scan_pushes(&g_jsp_ep, &obs); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, obs.n_pushes, + "an ordinary single-job notification must still produce exactly one " + "push packet, unchanged"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + Data_data_schedjobs, obs.last_data_which, + "a single-element notification must go through the EXISTING, " + "UNMODIFIED xcesched_push_joblist() path (Data.schedjobs), never the " + "new compact jobArray path (Req 8.1)"); + + jsp_teardown(); + printf("test_single_element_job_new_is_unaffected: PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Standalone Unity main (this binary is its own test executable — see */ +/* meson.build's sched_push_unit_test) */ +/* ------------------------------------------------------------------ */ + +void setUp(void) { return; } +void tearDown(void) { return; } + +int main(void) +{ + printf("\n>> job-array-single-wal-record task 5.1: compact scheduler " + "push\n\n"); + + UNITY_BEGIN(); + + RUN_TEST(test_multi_element_job_new_produces_one_compact_jobarray_push); + RUN_TEST(test_single_element_job_new_is_unaffected); + + return UNITY_END(); +}