/* test_apply_committed_entry.c — raft-apply-decoupling task 2.2 * * Proves apply_committed_entry() (persist.c) is the single dispatch seam * that turns ONE committed WAL entry into an in-memory mutation for every * classified entry type, and fires that entry type's post-mutation * notification hook exactly once, strictly after the mutation completes: * * - WAL_JOB_SUBMIT -> restore_job() + HOOK_DETAIL_JOB_NEW * - WAL_JOB_START/... -> apply_job_update_transition() (2.1's guarded * core) + HOOK_DETAIL_JOB_UPDATE * - WAL_QUEUE_CTRL -> restore_queue_state() + HOOK_DETAIL_QUEUE_UPDATE * - WAL_WORKER_CTRL -> worker_from_capnp() + HOOK_DETAIL_WORKER_JOIN * * and that a guard-rejected or idempotent-duplicate application performs no * mutation and fires NO notification (Req 7.1/7.2). * * This task does NOT wire apply_committed_entry into any live call site * (replay / apply pump are later tasks 2.3/6.1) — every test here calls the * dispatch function directly. * * Fixture: a minimal cluster_t (hooksys + jobmgr/qmgr/workermgr) and a bare * persist_t whose ONLY field apply_committed_entry reads is ->cluster — no * real WAL/disk/repl/commit-engine setup needed (mirrors test_apply_seam.c's * "bare fixture, no more than the function under test needs" philosophy). */ #include #include #include #include #include #include #include #include "cluster.h" #include "controller.capnp.h" #include "hook.h" #include "jobj.h" #include "persist.h" #include "qobj.h" #include "wal_types.h" #include "wobj.h" #include "xce/cluster_def.h" #include "xce/types.h" #include "xstring.h" /* jobj.c's jobsub table and the process-global xstring table are shared * across every test file in the controller_unit_test binary — guard * idempotently, mirroring test_apply_seam.c. */ static int g_tace_jobobj_ready; static cluster_t g_tace_c; static persist_t g_tace_p; /* ── Spy hook: counts AFTER_EVENT firings, ignores all payload args ─────── */ static int g_tace_hook_fires; static int tace_spy_hook(int type, int situation, int detail, void* data, va_list ap) { (void)type; (void)detail; (void)data; (void)ap; if (situation == HOOK_AFTER_EVENT) g_tace_hook_fires++; return 0; } /* ── Array-aware spy hook: like tace_spy_hook (counts every AFTER_EVENT * firing into the same g_tace_hook_fires counter), additionally recording * the xlist_t element count seen on the most recent HOOK_DETAIL_JOB_NEW * firing -- this is what lets a test distinguish "fired once carrying N * elements" from "fired N times carrying one element each" (task 3.2, * Req 3.4). va_arg order mirrors test_apply_pump.c's tap_spy_hook: the * WAL_JOB_SUBMIT/WAL_JOB_ARRAY_SUBMIT HOOK_DETAIL_JOB_NEW call shape is * (cluster_t*, xlist_t*) -- consume the leading cluster_t* first. ────────── */ static int g_tace_last_new_list_count; static int tace_array_spy_hook(int type, int situation, int detail, void* data, va_list ap) { (void)type; (void)data; if (situation != HOOK_AFTER_EVENT) return 0; g_tace_hook_fires++; if (detail == HOOK_DETAIL_JOB_NEW) { (void)va_arg(ap, cluster_t*); xlist_t* jlist = va_arg(ap, xlist_t*); g_tace_last_new_list_count = jlist ? jlist->count : -1; } return 0; } static void tace_setup(void) { kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT, .nslots = 32, .delta = 16 }; kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING, .nslots = 32, .delta = 16 }; if (makeString((char*)"tace-probe") == NULL) xstring_table_init(64); if (!g_tace_jobobj_ready) { TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32)); g_tace_jobobj_ready = 1; } memset(&g_tace_c, 0, sizeof(g_tace_c)); g_tace_c.myhost = &g_tace_c.myhost_info; g_tace_c.myhost_info.name = (char*)"ctrl-test"; g_tace_c.myhost_info.key = (char*)""; TEST_ASSERT_EQUAL_INT(0, xce_hook_init(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_MAX)); g_tace_c.jobmgr = makeInstance(&xkvmgr_type, &int_params); g_tace_c.qmgr = makeInstance(&xkvmgr_type, &str_params); g_tace_c.workermgr = makeInstance(&xkvmgr_type, &str_params); g_tace_c.resmgr = makeInstance(&xkvmgr_type, &str_params); TEST_ASSERT_NOT_NULL(g_tace_c.jobmgr); TEST_ASSERT_NOT_NULL(g_tace_c.qmgr); TEST_ASSERT_NOT_NULL(g_tace_c.workermgr); TEST_ASSERT_NOT_NULL(g_tace_c.resmgr); zarray_init(&g_tace_c.sharedinst, 16, NULL); memset(&g_tace_p, 0, sizeof(g_tace_p)); g_tace_p.cluster = &g_tace_c; g_tace_hook_fires = 0; } static void tace_teardown(void) { zarray_fini(&g_tace_c.sharedinst); if (g_tace_c.resmgr) dereference(g_tace_c.resmgr); if (g_tace_c.workermgr) dereference(g_tace_c.workermgr); if (g_tace_c.qmgr) dereference(g_tace_c.qmgr); if (g_tace_c.jobmgr) dereference(g_tace_c.jobmgr); xce_hook_fini(&g_tace_c.hooksys); } /* ── Encode helpers (mirror the static capnp_encode_{job,worker} in * persist.c — those are file-static, so tests keep local copies, exactly * as test_egress_filter.c's ef_encode_job already does). ────────────── */ static int tace_encode_job(const xce_job_t* j, uint8_t** out, size_t* out_len) { struct capn c; struct capn_segment* cs; struct Job cj; Job_ptr jp; capn_init_malloc(&c); cs = capn_root(&c).seg; encode_Job(cs, &cj, (xce_job_t*)j); jp = new_Job(cs); write_Job(&cj, jp); capn_setp(capn_root(&c), 0, jp.p); *out_len = capn_size(&c); *out = malloc(*out_len); if (!*out) { capn_free(&c); return -ENOMEM; } capn_write_mem(&c, *out, *out_len, 0); capn_free(&c); return 0; } static int tace_encode_worker(const xce_worker_t* w, uint8_t** out, size_t* out_len) { struct capn c; struct capn_segment* cs; struct Worker cw; Worker_ptr wp; capn_init_malloc(&c); cs = capn_root(&c).seg; encode_Worker(cs, &cw, (xce_worker_t*)w); wp = new_Worker(cs); write_Worker(&cw, wp); capn_setp(capn_root(&c), 0, wp.p); *out_len = capn_size(&c); *out = malloc(*out_len); if (!*out) { capn_free(&c); return -ENOMEM; } capn_write_mem(&c, *out, *out_len, 0); capn_free(&c); return 0; } /* Encodes a (base_jobid, array_size, spec) JobSubmit payload -- the exact * shape a committed WAL_JOB_ARRAY_SUBMIT record carries (mirrors * test_job_array_apply.c's taj_encode_job_submit and this file's own * tace_encode_job/tace_encode_worker pattern above). Caller frees *out. */ static int tace_encode_job_submit_array(uint64_t base_jobid, uint64_t array_size, job_submit_data_t* spec, uint8_t** out, size_t* out_len) { struct capn c; struct capn_segment* cs; struct JobSubmit cjs; JobSubmit_ptr jsp; xce_job_submit_t s; memset(&s, 0, sizeof(s)); s.jobid = base_jobid; s.array_size = array_size; s.submit = spec; capn_init_malloc(&c); cs = capn_root(&c).seg; encode_JobSubmit(cs, &cjs, &s); jsp = new_JobSubmit(cs); write_JobSubmit(&cjs, jsp); capn_setp(capn_root(&c), 0, jsp.p); *out_len = capn_size(&c); *out = malloc(*out_len); if (!*out) { capn_free(&c); return -ENOMEM; } capn_write_mem(&c, *out, *out_len, 0); capn_free(&c); return 0; } /* Look up a job_t wrapper by jobid (NULL if absent). */ static job_t* tace_find_job(uint64_t jobid) { job_t* j = NULL; lock(g_tace_c.jobmgr, RTTI_LOCK_READ); int found = key_find(g_tace_c.jobmgr, (void*)jobid, (void**)&j); unlock(g_tace_c.jobmgr); return (found > 0) ? j : NULL; } /* Creates a job directly in the fixture's jobmgr, bypassing * handle_jobsubmit/restore_job entirely — for seeding fact-transition tests. */ static job_submit_data_t g_tace_seed_submit; static job_t* tace_seed_job(uint64_t jobid, uint32_t state) { xce_job_t jdef; memset(&g_tace_seed_submit, 0, sizeof(g_tace_seed_submit)); g_tace_seed_submit.queue = (char*)"q"; g_tace_seed_submit.user = (char*)"tester"; memset(&jdef, 0, sizeof(jdef)); jdef.jobid = jobid; jdef.state = state; jdef.submit = &g_tace_seed_submit; return makeJob(g_tace_c.jobmgr, &jdef); } /* ── WAL_JOB_SUBMIT ───────────────────────────────────────────────────── */ static void test_ace_job_submit_constructs_and_notifies(void) { tace_setup(); job_submit_data_t submit; memset(&submit, 0, sizeof(submit)); submit.queue = (char*)"q"; submit.user = (char*)"tester"; xce_job_t jdef; memset(&jdef, 0, sizeof(jdef)); jdef.jobid = 501; jdef.state = JOB_STAT_PEND; jdef.submit = &submit; uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job(&jdef, &payload, &plen)); TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_spy_hook, NULL)); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_SUBMIT, 10, payload, (uint32_t)plen); TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc, "a genuine new job creation must return 1 (bug-1419: distinguishes " "creation from an idempotent duplicate no-op)"); job_t* j = tace_find_job(501); TEST_ASSERT_NOT_NULL_MESSAGE(j, "WAL_JOB_SUBMIT must construct the job via restore_job"); TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, j->job.state); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "a fresh job submit must fire exactly one post-mutation notification"); free(payload); tace_teardown(); printf("test_ace_job_submit_constructs_and_notifies: PASS\n"); } static void test_ace_job_submit_duplicate_is_noop_no_extra_notify(void) { tace_setup(); xce_job_t jdef; job_submit_data_t submit; memset(&submit, 0, sizeof(submit)); submit.queue = (char*)"q"; submit.user = (char*)"tester"; memset(&jdef, 0, sizeof(jdef)); jdef.jobid = 502; jdef.state = JOB_STAT_PEND; jdef.submit = &submit; uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job(&jdef, &payload, &plen)); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_spy_hook, NULL); TEST_ASSERT_EQUAL_INT_MESSAGE(1, apply_committed_entry(&g_tace_p, WAL_JOB_SUBMIT, 11, payload, (uint32_t)plen), "a genuine new job creation must return 1"); TEST_ASSERT_EQUAL_INT(1, g_tace_hook_fires); /* Re-apply the SAME entry (replay / resync redelivery) — must be a * no-op: no error, no second job, no second notification (Req 7.1). */ TEST_ASSERT_EQUAL_INT_MESSAGE(0, apply_committed_entry(&g_tace_p, WAL_JOB_SUBMIT, 11, payload, (uint32_t)plen), "an idempotent duplicate must return 0, not 1"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "an idempotent duplicate submit must not fire a second notification"); free(payload); tace_teardown(); printf("test_ace_job_submit_duplicate_is_noop_no_extra_notify: PASS\n"); } /* ── WAL_JOB_START / job-state fact family ───────────────────────────── */ static void test_ace_job_state_transition_applies_forward_and_notifies(void) { tace_setup(); job_t* j = tace_seed_job(601, JOB_STAT_PEND); TEST_ASSERT_NOT_NULL(j); wal_jobstate_t js = { .jobid = 601, .state = JOB_STAT_RUN }; xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_spy_hook, NULL); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_START, 20, &js, sizeof(js)); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j->job.state, "a forward job-state fact must apply via the 2.1 guarded core"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "a forward job-state transition must fire exactly one notification"); tace_teardown(); printf("test_ace_job_state_transition_applies_forward_and_notifies: " "PASS\n"); } static void test_ace_job_state_transition_rejects_backward_suppresses_notify(void) { tace_setup(); /* Job already terminal (as if FINISH already applied); a stale, * previously-unapplied START must not regress it (Req 7.1/7.2). */ job_t* j = tace_seed_job(602, JOB_STAT_DONE); TEST_ASSERT_NOT_NULL(j); wal_jobstate_t js = { .jobid = 602, .state = JOB_STAT_RUN }; xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_spy_hook, NULL); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_START, 21, &js, sizeof(js)); TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "a guard-rejected backward transition is a success no-op, not an " "apply failure"); TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j->job.state, "a rejected backward transition must preserve the terminal state"); TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_tace_hook_fires, "a guard-rejected transition must fire NO notification"); tace_teardown(); printf("test_ace_job_state_transition_rejects_backward_suppresses_" "notify: PASS\n"); } /* ── WAL_QUEUE_CTRL ───────────────────────────────────────────────────── */ static void test_ace_queue_ctrl_updates_and_notifies(void) { tace_setup(); xce_queue_t qdef; memset(&qdef, 0, sizeof(qdef)); qdef.queue = (char*)"q1"; TEST_ASSERT_NOT_NULL(makeQueue(g_tace_c.qmgr, &qdef)); const char* name = "q1"; size_t name_len = strlen(name) + 1; size_t total = sizeof(wal_queuehdr_t) + name_len; uint8_t* pkt = malloc(total); TEST_ASSERT_NOT_NULL(pkt); wal_queuehdr_t qh = { .status = QUEUE_STAT_CLOSED }; memcpy(pkt, &qh, sizeof(qh)); memcpy(pkt + sizeof(qh), name, name_len); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_QUEUE, tace_spy_hook, NULL); int rc = apply_committed_entry(&g_tace_p, WAL_QUEUE_CTRL, 30, pkt, (uint32_t)total); TEST_ASSERT_EQUAL_INT(0, rc); queue_t* q = NULL; lock(g_tace_c.qmgr, RTTI_LOCK_READ); key_find(g_tace_c.qmgr, (void*)name, (void**)&q); unlock(g_tace_c.qmgr); TEST_ASSERT_NOT_NULL(q); TEST_ASSERT_TRUE_MESSAGE((q->queue.status & QUEUE_STAT_CLOSED) != 0, "WAL_QUEUE_CTRL must apply via restore_queue_state"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "a queue control apply must fire exactly one notification"); free(pkt); tace_teardown(); printf("test_ace_queue_ctrl_updates_and_notifies: PASS\n"); } /* ── WAL_WORKER_CTRL ──────────────────────────────────────────────────── */ static void test_ace_worker_ctrl_join_restores_and_notifies(void) { tace_setup(); /* copy_worker (wobj.c) rejects a NULL src->attrs with -EINVAL — a real * WAL_WORKER_CTRL JOIN payload always carries real host attributes, so * the fixture must too. */ hostattrs_t attrs; memset(&attrs, 0, sizeof(attrs)); attrs.hosttype = (char*)"unknown"; attrs.hostmodel = (char*)"unknown"; xce_worker_t wdef; memset(&wdef, 0, sizeof(wdef)); wdef.name = (char*)"w1"; wdef.attrs = &attrs; uint8_t* wbuf = NULL; size_t wsize = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_worker(&wdef, &wbuf, &wsize)); uint32_t disc = (uint32_t)HOOK_DETAIL_WORKER_JOIN; size_t total = sizeof(disc) + wsize; uint8_t* pkt = malloc(total); TEST_ASSERT_NOT_NULL(pkt); memcpy(pkt, &disc, sizeof(disc)); memcpy(pkt + sizeof(disc), wbuf, wsize); free(wbuf); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_WORKER, tace_spy_hook, NULL); int rc = apply_committed_entry(&g_tace_p, WAL_WORKER_CTRL, 40, pkt, (uint32_t)total); TEST_ASSERT_EQUAL_INT(0, rc); worker_t* w = NULL; lock(g_tace_c.workermgr, RTTI_LOCK_READ); key_find(g_tace_c.workermgr, (void*)"w1", (void**)&w); unlock(g_tace_c.workermgr); TEST_ASSERT_NOT_NULL_MESSAGE(w, "WAL_WORKER_CTRL must restore the worker via worker_from_capnp"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "a worker control apply must fire exactly one notification"); free(pkt); tace_teardown(); printf("test_ace_worker_ctrl_join_restores_and_notifies: PASS\n"); } /* ── WAL_JOB_ARRAY_SUBMIT (job-array-single-wal-record task 3.2) ────────── */ static job_submit_data_t tace_array_make_spec(void) { job_submit_data_t s; memset(&s, 0, sizeof(s)); s.queue = (char*)"normal"; s.user = (char*)"tester"; s.mintasks = 1; s.maxtasks = 1; s.job_name = (char*)"array-job"; return s; } static void test_ace_job_array_submit_constructs_all_and_notifies_once(void) { tace_setup(); const uint64_t base_jobid = 5100; const uint64_t array_size = 4; job_submit_data_t spec = tace_array_make_spec(); uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job_submit_array(base_jobid, array_size, &spec, &payload, &plen)); TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_array_spy_hook, NULL)); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 60, payload, (uint32_t)plen); TEST_ASSERT_EQUAL_INT_MESSAGE((int)array_size, rc, "a genuine full-array creation must return the count of newly " "created elements (restore_job_array(), not restore_job(), was " "invoked)"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "applying one committed array record must fire exactly one " "notification event, never one per element"); TEST_ASSERT_EQUAL_INT_MESSAGE((int)array_size, g_tace_last_new_list_count, "the single notification must carry all N newly created elements " "in one list, not a 1-element list fired N times"); for (uint64_t i = 0; i < array_size; i++) { job_t* j = tace_find_job(base_jobid + i); TEST_ASSERT_NOT_NULL_MESSAGE(j, "every array index must be constructed via restore_job_array"); TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, j->job.state); TEST_ASSERT_EQUAL_UINT64_MESSAGE(60, j->submit_lsn, "each constructed element must be tagged with the entry's lsn"); } free(payload); tace_teardown(); printf("test_ace_job_array_submit_constructs_all_and_notifies_once: " "PASS\n"); } static void test_ace_job_array_submit_idempotent_replay_no_extra_notify(void) { tace_setup(); const uint64_t base_jobid = 5200; const uint64_t array_size = 3; job_submit_data_t spec = tace_array_make_spec(); uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job_submit_array(base_jobid, array_size, &spec, &payload, &plen)); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_array_spy_hook, NULL); TEST_ASSERT_EQUAL_INT_MESSAGE((int)array_size, apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 61, payload, (uint32_t)plen), "the first apply must genuinely construct every element"); TEST_ASSERT_EQUAL_INT(1, g_tace_hook_fires); /* Re-apply the SAME committed record (replay / resync redelivery): every * index already exists, so this is a whole-record idempotent no-op -- * restore_job_array() returns -EEXIST, apply_committed_entry() must * translate that to 0 and fire NO further notification (Req 3.3). */ int rc2 = apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 61, payload, (uint32_t)plen); TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc2, "an idempotent whole-record re-apply must return 0, not a genuine " "creation count"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "an idempotent duplicate array re-apply must not fire a second " "notification"); for (uint64_t i = 0; i < array_size; i++) { TEST_ASSERT_NOT_NULL(tace_find_job(base_jobid + i)); } free(payload); tace_teardown(); printf("test_ace_job_array_submit_idempotent_replay_no_extra_notify: " "PASS\n"); } #ifdef XCE_TESTING static void test_ace_job_array_submit_forced_failure_fires_no_hook(void) { tace_setup(); const uint64_t base_jobid = 5300; const uint64_t array_size = 5; job_submit_data_t spec = tace_array_make_spec(); uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job_submit_array(base_jobid, array_size, &spec, &payload, &plen)); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_array_spy_hook, NULL); /* Force restore_job_array()'s scratch-build loop to fail constructing * element index 2 -- simulates a resource-exhaustion apply failure * (Req 9.4). apply_committed_entry() must propagate the negative errno * and must NOT fire HOOK_DETAIL_JOB_NEW at all. */ job_array_apply_test_set_force_fail_at(2); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 62, payload, (uint32_t)plen); TEST_ASSERT_TRUE_MESSAGE(rc < 0, "a genuine construction failure must propagate as a negative errno"); TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_tace_hook_fires, "a failed array expansion must fire NO notification"); for (uint64_t i = 0; i < array_size; i++) { TEST_ASSERT_NULL_MESSAGE(tace_find_job(base_jobid + i), "an all-or-nothing failed attempt must leave nothing visible"); } /* Clear the seam and retry the identical committed record: this time * construction succeeds fully, the hook fires exactly once with all N * elements, and the earlier failed attempt did not poison this one. */ job_array_apply_test_clear_force_fail(); int rc2 = apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 62, payload, (uint32_t)plen); TEST_ASSERT_EQUAL_INT_MESSAGE((int)array_size, rc2, "a retry after the injected failure clears must construct every " "element"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "the successful retry must fire exactly one notification"); TEST_ASSERT_EQUAL_INT_MESSAGE((int)array_size, g_tace_last_new_list_count, "the retry's single notification must carry all N elements"); for (uint64_t i = 0; i < array_size; i++) { TEST_ASSERT_NOT_NULL(tace_find_job(base_jobid + i)); } free(payload); tace_teardown(); printf("test_ace_job_array_submit_forced_failure_fires_no_hook: PASS\n"); } /* ── job-array-single-wal-record task 7.1 (bug-1443 partial-prefix fix): * a bulk-insert-phase failure (the narrower residual gap, distinct from the * scratch-build-phase failure above) must STILL fire HOOK_DETAIL_JOB_NEW -- * for exactly the genuinely-inserted prefix -- while still returning the * negative rc so persist_apply_pump_run() halts/fences for the remainder. * Before this task, this exact scenario fired NO hook at all (see * test_ace_job_array_submit_forced_failure_fires_no_hook above, which forces * the OTHER, scratch-build-phase seam and correctly still expects no hook -- * that one never inserts anything real, so no hook is correct there; this * test's bulk-insert-phase seam DOES insert a real, permanent prefix, so a * hook is now required). */ static void test_ace_job_array_submit_bulk_insert_failure_fires_hook_for_prefix(void) { tace_setup(); const uint64_t base_jobid = 5400; const uint64_t array_size = 5; job_submit_data_t spec = tace_array_make_spec(); uint8_t* payload = NULL; size_t plen = 0; TEST_ASSERT_EQUAL_INT(0, tace_encode_job_submit_array(base_jobid, array_size, &spec, &payload, &plen)); xce_add_hook(&g_tace_c.hooksys, XCE_CONTROLLER_HOOK_JOB, tace_array_spy_hook, NULL); /* Force the bulk-insert pass (NOT the scratch-build pass) to fail at * index 2 -- indices 0 and 1 are genuinely, permanently inserted into * c->jobmgr via restore_job_array()'s real key_add() calls before this * fires. */ job_array_apply_test_set_bulk_insert_force_fail_at(2); int rc = apply_committed_entry(&g_tace_p, WAL_JOB_ARRAY_SUBMIT, 63, payload, (uint32_t)plen); TEST_ASSERT_TRUE_MESSAGE(rc < 0, "a bulk-insert-phase failure must still propagate as a negative" " errno so the caller halts/fences the LSN"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_tace_hook_fires, "the genuinely-inserted prefix must still get exactly ONE" " HOOK_DETAIL_JOB_NEW fire -- task 7.1's bug-1443 partial-prefix fix" " -- even though the overall call reports failure"); TEST_ASSERT_EQUAL_INT_MESSAGE(2, g_tace_last_new_list_count, "the fire must carry exactly the 2 genuinely-inserted elements, not" " the full array_size and not zero"); for (uint64_t i = 0; i < 2; i++) { TEST_ASSERT_NOT_NULL_MESSAGE(tace_find_job(base_jobid + i), "the genuinely-inserted prefix must be visible in c->jobmgr"); } for (uint64_t i = 2; i < array_size; i++) { TEST_ASSERT_NULL_MESSAGE(tace_find_job(base_jobid + i), "indices never reached by key_add() must not be visible"); } job_array_apply_test_clear_bulk_insert_force_fail(); free(payload); tace_teardown(); printf("test_ace_job_array_submit_bulk_insert_failure_fires_hook_for_" "prefix: PASS\n"); } #endif /* XCE_TESTING */ /* ── Defensive / boundary ─────────────────────────────────────────────── */ static void test_ace_null_persist_returns_error(void) { int rc = apply_committed_entry(NULL, WAL_JOB_UPDATE, 1, NULL, 0); TEST_ASSERT_NOT_EQUAL(0, rc); printf("test_ace_null_persist_returns_error: PASS\n"); } /* ── Test group entry point ───────────────────────────────────────────── */ void test_apply_committed_entry(void) { UnitySetTestFile(__FILE__); RUN_TEST(test_ace_job_submit_constructs_and_notifies); RUN_TEST(test_ace_job_submit_duplicate_is_noop_no_extra_notify); RUN_TEST(test_ace_job_state_transition_applies_forward_and_notifies); RUN_TEST(test_ace_job_state_transition_rejects_backward_suppresses_notify); RUN_TEST(test_ace_queue_ctrl_updates_and_notifies); RUN_TEST(test_ace_worker_ctrl_join_restores_and_notifies); RUN_TEST(test_ace_job_array_submit_constructs_all_and_notifies_once); RUN_TEST(test_ace_job_array_submit_idempotent_replay_no_extra_notify); #ifdef XCE_TESTING RUN_TEST(test_ace_job_array_submit_forced_failure_fires_no_hook); RUN_TEST(test_ace_job_array_submit_bulk_insert_failure_fires_hook_for_prefix); #endif RUN_TEST(test_ace_null_persist_returns_error); }