myxce/src/controller/testsuite/test_worker_resync.c
Rongsong Shen 6876e4f6f2
fix(controller): persist a failed job's exit code through WAL_JOB_FAIL (bug-1754)
A worker reports every nonzero job exit as a FAIL (xcecd_job_finished ->
xcecd_notify_job_fail(jobid, stage, exit_code)), not a FINISH. The deferred
WAL_JOB_FAIL fact carried only {jobid, STAGE_FLAG|stage} and no apply seam
touched job.exit_info, so exit_info stayed NULL for every failed job, live
and after replay, and HOOK_DETAIL_JOB_FAIL subscribers saw code 0.

- persist_submit_job_fail_fact(): header + int32_t code trailer, used by
  record_job_fail_fact(). Old entries (no trailer) decode as before.
- apply_job_fail_exit_code(): records the code in exit_info for pre-exit
  stages only; a POST_EXEC code is the script's and leaves exit_info alone.
  Kept out of apply_job_fail_transition() so mark_job_fail()'s internal
  -errno failures (not replayed with a code) stay live/replay consistent.
- Both apply seams adopt the code. apply_wal_entry()'s WAL_JOB_FAIL case
  also stops assigning the wire state 0 for STAGE_FLAG entries and runs the
  real transition, as apply_committed_entry() already does.

New test_wal_job_fail_fact.c (5 tests, 3 RED before the fix); the two
test_worker_resync.c assertions that pinned exit_info == NULL now assert the
worker-reported code. Controller suite: 1228 tests, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 03:32:54 +00:00

4832 lines
207 KiB
C

/* test_worker_resync.c — worker-rejoin-resync task 2.1
*
* Proves record_job_reconcile_pending_fact() (job.c) — the Requirement 4.6
* new fact-recording function that returns a worker-unrecognized job to
* PEND, releasing any resource allocation it was carrying (bug-1631's own
* deliberately-deferred trigger condition, closed here — see design.md's
* Error Handling section) and durably appending a plain WAL_JOB_UPDATE fact
* -- following dispatch_a_job_()'s eager-mutation-plus-explicit-notify
* pattern (design.md "Controller: job.c — record_job_reconcile_pending_
* fact, record_job_reconcile_assign_fact"), NOT record_job_finish_fact's
* deferred one.
*
* This task implements ONLY record_job_reconcile_pending_fact() itself --
* no live caller exists yet (dispatch_a_job_'s disconnected-worker split is
* task 2.3; resync.c's report-apply glue is a later task), so every test
* here directly exercises the exact sequence a real caller will perform:
* lock -> call record_job_reconcile_pending_fact() -> check the returned
* job_transition_result_t -> unlock -> (only when APPLIED) call
* persist_notify_job_updated_already_persisted() itself -- proving the full
* intended sequence end-to-end even though production wiring of that
* sequence is a later task's job (design.md's Service Interface
* Postconditions/Invariants: the caller, not this function, owns that
* post-unlock notify call).
*
* Fixture mirrors test_dispatch_commit.c's dc_setup/dc_teardown exactly: a
* bare cluster_t (jobmgr + hooksys) plus a real disk-backed persist_t
* initialized with cluster == NULL, so wal_hook_append_classified()
* (persist.c) takes the direct persist_wal_async() path for
* persist_submit_job_fact()'s plain RETAIN-mode WAL_JOB_UPDATE append -- no
* raft/commit-engine machinery needed. p->current_lsn before/after each
* call proves (or disproves) a durable append happened, mirroring
* test_dispatch_commit.c's test_dc_worker_not_found_leaves_job_pend_and_
* alloc_uncommitted's own LSN-delta idiom.
*/
#include <rtti.h>
#include <capnp_c.h>
#include <errno.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "cluster.h"
#include "controller.capnp.h"
#include "hook.h"
#include "jobj.h"
#include "kvmgr.h"
#include "netio.h" /* worker-rejoin-resync task 7.1: endpoint_get/
* endpoint_deq/ITPTR_TO_PACKET, for real wire capture of
* the round-trip test's query send / report send */
#include "persist.h"
#include "request.h" /* worker-rejoin-resync task 7.1: xce_decode,
* xce_free_request, xce_job_state_report_req */
#include "resobj.h" /* worker-rejoin-resync task 7.1: makeResource/
* xce_resource_t, for the round-trip test's real
* resmgr (apply_join_state, reached via rejoin_worker,
* needs one) */
#include "resync.h" /* worker-rejoin-resync task 3.1 */
#include "wal_types.h"
#include "wobj.h" /* worker-rejoin-resync task 3.1: makeWorker, for a real
* RTTI-managed worker_t the timeout test can genuinely
* lock() */
#include "xce/cluster_def.h"
#include "xce/types.h"
#include "xcealloc.h"
#include "xstring.h"
/* worker-rejoin-resync task 7.1: rejoin_worker/worker_send_resync_query are
* non-static in worker.c when XCE_TESTING is defined -- forward-declared
* directly here rather than via a header, mirroring test_worker_join.c's
* own identical forward declarations exactly (these functions have no
* public header of their own; test_worker_join.c already establishes this
* as the file's own convention for driving the real join path from a
* test). collect_confvars (needed for worker.c to link, since on_worker_
* join -> worker_push_config -> collect_confvars) is already provided by
* test_worker_join.c elsewhere in this SAME controller_unit_test binary --
* not redefined here to avoid a duplicate-symbol link error; this test
* never calls worker_push_config anyway. */
int rejoin_worker(cluster_t* c, worker_t* w, endpoint_t* ep,
hostattrs_t* attrs, xce_hostconfig_t* hconfig);
int worker_send_resync_query(cluster_t* c, worker_t* w, endpoint_t* ep);
/* jobj.c's job type table and the process-global xstring table are shared
* across every test file in the controller_unit_test binary -- guard
* idempotently, mirroring every sibling fixture in this suite
* (test_wal_job_dispatch_fact.c's g_wjd_jobobj_ready). */
static int g_twr_jobobj_ready;
static cluster_t g_twr;
static char g_twr_tmpdir[256];
/* worker-rejoin-resync task 3.1: no-op kvmgr datakiller for g_twr.workermgr,
* mirroring production's own worker_obj_killer (cluster.c) -- a worker_t,
* once created, is never explicitly torn down for the life of the process
* (no key_del(c->workermgr, ...) call site exists anywhere in this
* codebase), so there is nothing for this to actually free. */
static void twr_worker_obj_killer(void* arg)
{
(void)arg;
}
static void twr_setup(void)
{
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
if (makeString((char*)"twr-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&g_twr, 0, sizeof(g_twr));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&g_twr.hooksys, XCE_CONTROLLER_HOOK_MAX));
g_twr.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(g_twr.jobmgr);
/* worker-rejoin-resync task 3.1: a real workermgr so the round-lifecycle
* tests below can create a genuine RTTI-managed worker_t (via
* makeWorker) where that matters (the timeout test, whose internal
* resync_on_timeout callback genuinely lock()s `w`) -- key type mirrors
* production's own workermgr_params (cluster.c, KVMGR_KEY_STRING,
* keyed by hostname). */
{
kvmgr_params_t workermgr_params
= { .key_type = KVMGR_KEY_STRING,
.nslots = 32,
.delta = 16,
.datakiller = twr_worker_obj_killer };
g_twr.workermgr = makeInstance(&xkvmgr_type, &workermgr_params);
TEST_ASSERT_NOT_NULL(g_twr.workermgr);
}
/* Real disk-backed persist, cluster == NULL: wal_hook_append_
* classified() (persist.c) takes the direct persist_wal_async() path --
* no raft/commit-engine machinery needed for a plain RETAIN-mode fact
* append (mirrors test_dispatch_commit.c's dc_setup exactly, which
* exercises dispatch_a_job_'s own real persist_submit_job_fact()-family
* call the same way). */
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
strcpy(g_twr_tmpdir, "/tmp/xce_twr21_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_twr_tmpdir));
persist_set_workdir(&g_twr.persist, g_twr_tmpdir, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&g_twr.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&g_twr.persist));
}
static void twr_teardown(void)
{
char cmd[512];
persist_stop(&g_twr.persist);
persist_fini(&g_twr.persist);
if (g_twr.jobmgr)
dereference(g_twr.jobmgr);
if (g_twr.workermgr)
dereference(g_twr.workermgr);
xce_hook_fini(&g_twr.hooksys);
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_twr_tmpdir);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", g_twr_tmpdir);
}
/* Creates a job directly in the fixture's jobmgr, bypassing dispatch/replay
* entirely -- for seeding fact-transition tests, mirroring
* test_wal_job_dispatch_fact.c's wjd_seed_job / test_dispatch_commit.c's
* dc_seed_ready. */
static job_submit_data_t g_twr_seed_submit;
static job_t* twr_seed_job(uint64_t jobid, uint32_t state)
{
xce_job_t jdef;
memset(&g_twr_seed_submit, 0, sizeof(g_twr_seed_submit));
g_twr_seed_submit.queue = (char*)"q";
g_twr_seed_submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = jobid;
jdef.state = state;
jdef.submit = &g_twr_seed_submit;
return makeJob(g_twr.jobmgr, &jdef);
}
/* One host ("twr-worker"), one resource ("slots", value 1.0) -- the same
* minimal shape test_wal_job_dispatch_fact.c's/test_dispatch_commit.c's own
* dispatch-alloc tests use. Caller owns the result (release via
* free_JobAlloc_ptr, or hand ownership to job->job.allocs). */
static xce_job_alloc_t* twr_make_test_alloc(uint64_t jobid)
{
return make_job_alloc(jobid, 1,
make_host_allocs(1,
make_host_alloc((char*)"twr-worker", 1,
make_allocs(1,
make_alloc((char*)"slots", NULL, 0, 1.0f, 0)))));
}
static uint64_t twr_current_lsn(void)
{
uint64_t lsn;
pthread_mutex_lock(&g_twr.persist.lock);
lsn = g_twr.persist.current_lsn;
pthread_mutex_unlock(&g_twr.persist.lock);
return lsn;
}
/* Spy hook: counts HOOK_AFTER_EVENT/HOOK_DETAIL_JOB_UPDATE fires -- the
* exact hook sched_job_update_fun/apiserver_job_update_fun subscribe to in
* production, mirroring test_dispatch_commit.c's dc_job_update_spy_hook. */
static int g_twr_update_hook_fires;
static int twr_job_update_spy_hook(int type, int situation, int detail,
void* data, va_list ap)
{
(void)type; (void)data; (void)ap;
if (situation == HOOK_AFTER_EVENT && detail == HOOK_DETAIL_JOB_UPDATE)
g_twr_update_hook_fires++;
return 0;
}
#define TWR_JOBID_APPLIED 81001ULL
#define TWR_JOBID_NOOP 81002ULL
#define TWR_JOBID_TERMINAL 81003ULL
/* THE core proof (Req 4.6, 7.1-7.3; bug-1631 regression): a job that was
* dispatched (real allocs committed, state RUN) and reconciles to
* unrecognized ends the call -- synchronously, BEFORE any commit round-trip,
* matching dispatch_a_job_'s own eager pattern, NOT record_job_finish_
* fact's deferred one -- with PEND state and no allocation, a durably
* appended fact, and (once the caller unlocks and notifies, exactly as a
* real caller must) the scheduler's notify path fired exactly once. */
static void test_reconcile_pending_applies_releases_allocs_and_notifies(void)
{
job_t* j;
xce_job_alloc_t* alloc;
int rc;
uint64_t lsn_before, lsn_after;
twr_setup();
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&g_twr.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
j = twr_seed_job(TWR_JOBID_APPLIED, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
alloc = twr_make_test_alloc(TWR_JOBID_APPLIED);
TEST_ASSERT_NOT_NULL(alloc);
j->job.allocs = alloc; /* simulate dispatch_a_job_'s own committed alloc,
* ownership transferred to job->job.allocs. */
lsn_before = twr_current_lsn();
lock(j, RTTI_LOCK_WRITE);
rc = record_job_reconcile_pending_fact(&g_twr, j);
/* Observable completion (design.md): PEND + no allocation, asserted
* IMMEDIATELY after the call returns, still under the write lock,
* before any commit round-trip -- the mutation is eager, not
* deferred. */
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_TRANSITION_APPLIED, rc,
"an unrecognized RUN job must apply the PEND transition");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, j->job.state,
"job must be PEND synchronously, before any WAL commit/apply "
"round trip");
TEST_ASSERT_NULL_MESSAGE(j->job.allocs,
"bug-1631: a job reconciled from RUN back to PEND must release its "
"resource allocation immediately, so a later redispatch never "
"inherits a stale allocation from the prior dispatch");
unlock(j);
lsn_after = twr_current_lsn();
TEST_ASSERT_TRUE_MESSAGE(lsn_after > lsn_before,
"record_job_reconcile_pending_fact must durably append a "
"WAL_JOB_UPDATE fact for an applied transition");
/* Caller's responsibility, AFTER unlocking (design.md Postconditions/
* Invariants): notify the scheduler through the existing notify path,
* exactly once, for this actual mutation (Req 7.1-7.3). */
persist_notify_job_updated_already_persisted(&g_twr, j);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_update_hook_fires,
"the scheduler notify path must fire exactly once for this "
"reconciled pending transition (Req 7.2) when the caller notifies "
"after unlocking -- omitting it would reproduce bug-1679 at this "
"new call site");
xce_disable_hook(&g_twr.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
twr_teardown();
printf("test_reconcile_pending_applies_releases_allocs_and_notifies: "
"PASS\n");
}
/* A job with no allocation at all (e.g. never dispatched, already PEND):
* reconciling it again must be a no-op per apply_job_update_transition's
* own duplicate-state guard (JOB_TRANSITION_NOOP_DUPLICATE) -- no mutation,
* no allocs touch (there is none to release), no durable fact, no crash
* (Req 4.3). */
static void test_reconcile_pending_noop_on_already_pending_job(void)
{
job_t* j;
int rc;
uint64_t lsn_before, lsn_after;
twr_setup();
j = twr_seed_job(TWR_JOBID_NOOP, JOB_STAT_PEND);
TEST_ASSERT_NOT_NULL(j);
TEST_ASSERT_NULL(j->job.allocs);
lsn_before = twr_current_lsn();
lock(j, RTTI_LOCK_WRITE);
rc = record_job_reconcile_pending_fact(&g_twr, j);
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_TRANSITION_NOOP_DUPLICATE, rc,
"reconciling an already-PEND job must be treated as a duplicate "
"no-op, matching apply_job_update_transition's own guard");
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, j->job.state);
TEST_ASSERT_NULL_MESSAGE(j->job.allocs,
"a job that never held an allocation must still have none after a "
"no-op reconcile");
unlock(j);
lsn_after = twr_current_lsn();
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, lsn_after,
"a no-op reconcile must append no duplicate WAL fact (Req 4.3)");
twr_teardown();
printf("test_reconcile_pending_noop_on_already_pending_job: PASS\n");
}
/* A job the controller already knows is terminal (DONE) must never be
* clobbered back to PEND by a reconciliation report (Req 4.4) -- the
* existing job_state_is_backward guard inside apply_job_update_transition,
* reused unmodified, rejects it. */
static void test_reconcile_pending_rejects_terminal_job(void)
{
job_t* j;
int rc;
uint64_t lsn_before, lsn_after;
twr_setup();
j = twr_seed_job(TWR_JOBID_TERMINAL, JOB_STAT_DONE);
TEST_ASSERT_NOT_NULL(j);
lsn_before = twr_current_lsn();
lock(j, RTTI_LOCK_WRITE);
rc = record_job_reconcile_pending_fact(&g_twr, j);
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_TRANSITION_REJECTED_BACKWARD, rc,
"a terminal job must reject the backward PEND transition, never "
"clobbering more-final state (Req 4.4)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j->job.state,
"the job's terminal state must be preserved exactly");
unlock(j);
lsn_after = twr_current_lsn();
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, lsn_after,
"a rejected reconcile must append no WAL fact");
twr_teardown();
printf("test_reconcile_pending_rejects_terminal_job: PASS\n");
}
/* ── record_job_reconcile_assign_fact() tests (task 2.2) ─────────────────
*
* Proves record_job_reconcile_assign_fact() (job.c) -- the Requirement 4.8
* new fact-recording function that records a job the worker reports running
* that the controller has no existing non-terminal record of ("unqueried
* extra", Req 3.6) as assigned to the reporting worker, through the same
* dispatch_a_job_()-style eager-mutation-plus-explicit-notify pattern as
* record_job_reconcile_pending_fact above, reusing bug-1678's NULL-alloc
* persist_submit_job_dispatch_fact() path (design.md "Controller: job.c --
* record_job_reconcile_pending_fact, record_job_reconcile_assign_fact").
*
* As with task 2.1, no live caller exists yet (resync.c's report-apply glue
* is a later task), so every test here directly exercises the exact
* sequence a real caller will perform: lock -> call
* record_job_reconcile_assign_fact() -> check the result -> unlock -> call
* persist_notify_job_updated_already_persisted() itself. */
#define TWR_JOBID_ASSIGN 81004ULL
#define TWR_JOBID_ASSIGN_REPLAY 81005ULL
/* THE core proof (Req 4.1, 4.3, 4.4, 4.7, 4.8, 7.1-7.3): a job the
* controller never previously tracked, reported RUNNING by a worker, ends
* the call -- synchronously, before any commit round-trip -- recorded as
* running with no resource allocation (the controller genuinely does not
* know what the scheduler originally computed, design.md's "Documented
* scope decision"), a durably appended fact, and (once the caller unlocks
* and notifies, exactly as a real caller must) the scheduler's notify path
* fired exactly once. */
static void
test_reconcile_assign_applies_running_no_allocs_and_notifies(void)
{
job_t* j;
worker_t w;
xce_job_state_entry_t report;
int rc;
uint64_t lsn_before, lsn_after;
twr_setup();
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&g_twr.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
/* Seeded with no allocation, mirroring a job the caller (task 3.2, not
* yet implemented) constructed fresh for a jobid the controller had
* never seen before -- there is nothing to release here, only
* something new to record. */
j = twr_seed_job(TWR_JOBID_ASSIGN, JOB_STAT_PEND);
TEST_ASSERT_NOT_NULL(j);
TEST_ASSERT_NULL(j->job.allocs);
memset(&w, 0, sizeof(w));
w.host.name = (char*)"twr-worker";
report = (xce_job_state_entry_t){
.jobid = TWR_JOBID_ASSIGN,
.status = JobStateStatus_running,
.exit_code = 0,
.finish_time_ms = 0,
.queried = 0
};
lsn_before = twr_current_lsn();
lock(j, RTTI_LOCK_WRITE);
rc = record_job_reconcile_assign_fact(&g_twr, j, &w, &report);
/* Observable completion (design.md / this task): RUN + no allocation,
* asserted IMMEDIATELY after the call returns, still under the write
* lock, before any commit round-trip -- the mutation is eager, not
* deferred. */
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_TRANSITION_APPLIED, rc,
"an unqueried running job must always apply (no reject/no-op case)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j->job.state,
"job must be RUN synchronously, before any WAL commit/apply round "
"trip");
TEST_ASSERT_NULL_MESSAGE(j->job.allocs,
"Req 4.8: a reconciliation-assigned job must never carry a "
"fabricated resource allocation -- the controller genuinely does "
"not know what the scheduler originally computed for it");
unlock(j);
lsn_after = twr_current_lsn();
TEST_ASSERT_TRUE_MESSAGE(lsn_after > lsn_before,
"record_job_reconcile_assign_fact must durably append a "
"WAL_JOB_DISPATCH fact");
/* Caller's responsibility, AFTER unlocking (design.md Postconditions/
* Invariants): notify the scheduler through the existing notify path,
* exactly once, for this actual mutation (Req 7.1-7.3). */
persist_notify_job_updated_already_persisted(&g_twr, j);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_update_hook_fires,
"the scheduler notify path must fire exactly once for this "
"reconciled assignment (Req 7.2) when the caller notifies after "
"unlocking -- omitting it would reproduce bug-1679 at this new "
"call site");
xce_disable_hook(&g_twr.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
twr_teardown();
printf("test_reconcile_assign_applies_running_no_allocs_and_notifies: "
"PASS\n");
}
/* Encode helper mirroring test_dispatch_commit.c's dc_encode_job /
* test_wal_job_dispatch_fact.c's wjd_encode_job (capnp_encode_job is
* file-static in persist.c, so a direct WAL_JOB_SUBMIT seed needs its own
* encoder built from the exported encode_Job/new_Job/write_Job codec
* chain). */
static int twr_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;
}
/* Fresh disk-backed persist_t pointed at an existing tmpdir -- mirrors
* test_dispatch_commit.c's dc_make_fresh_persist/dc_teardown_persist
* exactly, for this test's own Phase 2 (simulated restart). */
static persist_t* twr_make_fresh_persist(const char* tmpdir)
{
persist_t* p = calloc(1, sizeof(persist_t));
persist_config_t cfg;
if (!p) return NULL;
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(p, tmpdir, "ctrl-test");
if (persist_init(p, &cfg, NULL) != 0) {
free(p);
return NULL;
}
if (persist_start(p) != 0) {
persist_fini(p);
free(p);
return NULL;
}
return p;
}
static void twr_teardown_persist(persist_t* p)
{
persist_stop(p);
persist_fini(p);
free(p);
}
/* Restart-replay round trip -- this task's own explicit completion
* criterion ("durably survives a restart replay with the same result"),
* mirroring test_dispatch_commit.c's test_dc_success_appends_wal_job_
* dispatch_durably's Phase 1/Phase 2 shape exactly, but driving Phase 1's
* WAL_JOB_DISPATCH fact via record_job_reconcile_assign_fact() instead of
* dispatch_a_job_() -- proving bug-1678's NULL-alloc decode branch (already
* tested on both apply seams by test_wal_job_dispatch_fact.c) round-trips
* this new call site's real fact correctly too. Uses its own local
* cluster_t/persist_t (not the shared g_twr fixture) so Phase 1's disk
* state survives past that phase's own teardown, exactly like dc's
* original. */
static void test_reconcile_assign_survives_restart_replay(void)
{
char tmp[256];
uint64_t captured_start_time = 0;
strcpy(tmp, "/tmp/xce_twr22_replay_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Phase 1: real cluster + real disk persist. Seed a durable
* WAL_JOB_SUBMIT fact directly (so replay can reconstruct the job --
* this job was never dispatched through any controller, so there is no
* other WAL_JOB_SUBMIT/DISPATCH in this log), seed the in-memory job_t
* the same way twr_seed_job() does (bypassing dispatch/replay --
* record_job_reconcile_assign_fact() operates on a job_t* the caller
* has already located/constructed, not this function's concern), then
* drive the real function under test. */
{
cluster_t c;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
job_t* j;
worker_t w;
xce_job_state_entry_t report;
int rc;
if (makeString((char*)"twr22-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
{
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_ASSIGN_REPLAY;
wal_jdef.state = JOB_STAT_PEND;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
}
{
job_submit_data_t submit;
xce_job_t jdef;
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_ASSIGN_REPLAY;
jdef.state = JOB_STAT_PEND;
jdef.submit = &submit;
j = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(j);
}
memset(&w, 0, sizeof(w));
w.host.name = (char*)"twr-worker-replay";
report = (xce_job_state_entry_t){
.jobid = TWR_JOBID_ASSIGN_REPLAY,
.status = JobStateStatus_running,
.exit_code = 0,
.finish_time_ms = 0,
.queried = 0
};
lock(j, RTTI_LOCK_WRITE);
rc = record_job_reconcile_assign_fact(&c, j, &w, &report);
TEST_ASSERT_EQUAL_INT(JOB_TRANSITION_APPLIED, rc);
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, j->job.state);
TEST_ASSERT_NULL(j->job.allocs);
captured_start_time = (uint64_t)j->job.start_time;
TEST_ASSERT_NOT_EQUAL(0, captured_start_time);
unlock(j);
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
}
/* Phase 2: fresh persist_t + fresh cluster_t (jobmgr/hooksys only),
* recover from disk -- simulates a controller restart. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2 = NULL;
int rc;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_ASSIGN_REPLAY, (void**)&j2);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL_MESSAGE(j2,
"restart replay must reconstruct the job via WAL_JOB_SUBMIT");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(TWR_JOBID_ASSIGN_REPLAY,
j2->job.jobid, "recovered job must be the same jobid");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j2->job.state,
"restart replay must recover the job as RUNNING from the "
"WAL_JOB_DISPATCH fact appended by record_job_reconcile_"
"assign_fact()'s real call (Req 4.8) -- no WAL_JOB_START "
"anywhere in this log");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(captured_start_time,
(uint64_t)j2->job.start_time,
"restart replay must recover the exact dispatch-time "
"start_time stamped by the real call in Phase 1");
TEST_ASSERT_NULL_MESSAGE(j2->job.allocs,
"Req 4.8 / design.md's documented scope decision: replay must "
"NOT fabricate a resource allocation for a reconciliation-"
"assigned job -- allocs stays NULL exactly as it was set "
"eagerly in Phase 1, exercising bug-1678's NULL-alloc decode "
"branch");
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
printf("test_reconcile_assign_survives_restart_replay: PASS\n");
}
/* ── resync.c round lifecycle tests (task 3.1) ───────────────────────────
*
* Proves resync_arm_round()/resync_resolve_round()/resync_round_is_current()
* (resync.c) -- the per-worker job-state reconciliation round lifecycle:
* query-set construction (Req 1.1, 2.1, 2.2), a fresh monotonic round token
* per arm distinguishing this round from any prior one for the same worker
* (Req 6.1, 6.3), the XCE_WORKER_RECONCILING status bit (Req 5.1, 5.2), and
* the Req 5.3 bounded timeout resolving the round without any blocking
* call. Reuses the g_twr/twr_setup/twr_teardown fixture and twr_seed_job()
* from task 2.1/2.2's tests above -- resync_arm_round only needs a
* cluster_t with a real c->jobmgr (which g_twr already provides).
*
* No live caller exists yet (worker.c's rejoin_worker/apply_join_state
* wiring is task 6.1; the report-apply glue that would call
* resync_round_is_current for real is task 3.2) -- every test here calls
* resync_arm_round/resync_resolve_round/resync_round_is_current directly,
* exactly as a real caller (task 6.1) eventually will.
*
* resync_arm_round/resync_resolve_round/resync_round_is_current never
* lock/unlock `w` themselves (their documented precondition is that the
* CALLER already holds it) -- so a bare stack worker_t is sufficient and
* safe for those tests, exactly mirroring how task 2.2's own
* record_job_reconcile_assign_fact tests above already use a bare stack
* `worker_t w` with no locking. The ONE exception is the timeout test:
* resync_on_timeout (the internal delaymgr callback resync_test_fire_
* timeout drives directly) runs with no caller-held lock -- because a real
* delaymgr fire has none -- so it genuinely calls lock()/unlock() on `w`
* itself, which requires a real RTTI-managed worker_t (via makeWorker
* against g_twr.workermgr), not a bare stack struct. */
#define TWR_JOBID_RESYNC_A 81101ULL
#define TWR_JOBID_RESYNC_B 81102ULL
#define TWR_JOBID_RESYNC_OTHER 81103ULL
#define TWR_JOBID_RESYNC_DONE 81104ULL
#define TWR_JOBID_RESYNC_NOALLOC 81105ULL
#define TWR_JOBID_RESYNC_SOLO 81106ULL
#define TWR_JOBID_RESYNC_TIMEOUT 81107ULL
/* Same builder chain as twr_make_test_alloc() above, parameterized by host
* -- twr_make_test_alloc() itself hardcodes "twr-worker", which does not
* fit these tests' need for several DISTINCT hosts within one job table. */
static xce_job_alloc_t* twr_make_test_alloc_for_host(uint64_t jobid,
const char* host)
{
return make_job_alloc(jobid, 1,
make_host_allocs(1,
make_host_alloc((char*)host, 1,
make_allocs(1,
make_alloc((char*)"slots", NULL, 0, 1.0f, 0)))));
}
/* True iff `jobid` appears anywhere in ids[0..n) -- used below to prove the
* query set contains exactly the expected members, neither missing a real
* match nor carrying an extra one. */
static int twr_ids_contains(const uint64_t* ids, int n, uint64_t jobid)
{
int i;
for (i = 0; i < n; i++) {
if (ids[i] == jobid)
return 1;
}
return 0;
}
/* A real RTTI-managed worker_t (via makeWorker against g_twr.workermgr),
* for the one test below that needs genuine lock()/unlock() to succeed on
* `w` -- mirrors worker.c's own init_worker() construction
* (`tmp.name = (char*)host; tmp.attrs = attrs; w = makeWorker(c->workermgr,
* &tmp);`). */
static worker_t* twr_make_test_worker(const char* hostname)
{
hostattrs_t attrs;
xce_worker_t tmp;
memset(&attrs, 0, sizeof(attrs));
memset(&tmp, 0, sizeof(tmp));
tmp.name = (char*)hostname;
tmp.attrs = &attrs;
return makeWorker(g_twr.workermgr, &tmp);
}
/* Req 1.1, 2.1, 2.2: the query set matches EXACTLY the non-terminal jobs
* assigned to the given host -- not fewer (a genuine match omitted), not
* more (a different host's job, a terminal job on the SAME host, or a
* never-dispatched job with no host assignment at all, leaking in). */
static void test_resync_arm_round_query_set_matches_exactly(void)
{
job_t* ja;
job_t* jb;
job_t* jother;
job_t* jdone;
job_t* jnoalloc;
worker_t w;
int rc;
twr_setup();
ja = twr_seed_job(TWR_JOBID_RESYNC_A, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(ja);
ja->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_A, "resync-host-A");
jb = twr_seed_job(TWR_JOBID_RESYNC_B, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(jb);
jb->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_B, "resync-host-A");
/* Same non-terminal state, DIFFERENT host -- must NOT appear in
* resync-host-A's query set. */
jother = twr_seed_job(TWR_JOBID_RESYNC_OTHER, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(jother);
jother->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_OTHER,
"resync-host-B");
/* SAME host, but terminal (DONE) -- must NOT appear (Req 1.1's
* "non-terminal" qualifier). */
jdone = twr_seed_job(TWR_JOBID_RESYNC_DONE, JOB_STAT_DONE);
TEST_ASSERT_NOT_NULL(jdone);
jdone->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_DONE, "resync-host-A");
/* Never dispatched (no allocation at all) -- not assigned to ANY host,
* so must NOT appear regardless of state. */
jnoalloc = twr_seed_job(TWR_JOBID_RESYNC_NOALLOC, JOB_STAT_PEND);
TEST_ASSERT_NOT_NULL(jnoalloc);
TEST_ASSERT_NULL(jnoalloc->job.allocs);
memset(&w, 0, sizeof(w));
w.host.name = (char*)"resync-host-A";
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT_MESSAGE(2, rc,
"exactly the 2 non-terminal resync-host-A jobs must be armed into "
"the round");
TEST_ASSERT_NOT_NULL(w.resync);
TEST_ASSERT_EQUAL_INT(2, w.resync->n_queried_jobids);
TEST_ASSERT_TRUE_MESSAGE(
twr_ids_contains(w.resync->queried_jobids, w.resync->n_queried_jobids,
TWR_JOBID_RESYNC_A),
"job A (non-terminal, resync-host-A) must be in the query set");
TEST_ASSERT_TRUE_MESSAGE(
twr_ids_contains(w.resync->queried_jobids, w.resync->n_queried_jobids,
TWR_JOBID_RESYNC_B),
"job B (non-terminal, resync-host-A) must be in the query set");
TEST_ASSERT_FALSE_MESSAGE(
twr_ids_contains(w.resync->queried_jobids, w.resync->n_queried_jobids,
TWR_JOBID_RESYNC_OTHER),
"a job assigned to a DIFFERENT host must not appear");
TEST_ASSERT_FALSE_MESSAGE(
twr_ids_contains(w.resync->queried_jobids, w.resync->n_queried_jobids,
TWR_JOBID_RESYNC_DONE),
"a TERMINAL job on the same host must not appear (Req 1.1)");
TEST_ASSERT_FALSE_MESSAGE(
twr_ids_contains(w.resync->queried_jobids, w.resync->n_queried_jobids,
TWR_JOBID_RESYNC_NOALLOC),
"a never-dispatched job (no host assignment at all) must not "
"appear");
TEST_ASSERT_TRUE_MESSAGE(IS_WORKER_RECONCILING(w.host.status),
"XCE_WORKER_RECONCILING must be set once a round is armed");
resync_resolve_round(&g_twr, &w);
TEST_ASSERT_NULL(w.resync);
TEST_ASSERT_FALSE(IS_WORKER_RECONCILING(w.host.status));
twr_teardown();
printf("test_resync_arm_round_query_set_matches_exactly: PASS\n");
}
/* Req 1.2: a worker with zero non-terminal jobs assigned is a documented
* no-op -- no round armed, no status bit set. */
static void test_resync_arm_round_no_jobs_is_noop(void)
{
worker_t w;
int rc;
twr_setup();
memset(&w, 0, sizeof(w));
w.host.name = (char*)"resync-host-empty";
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"a worker with no non-terminal jobs must arm zero jobs (Req 1.2)");
TEST_ASSERT_NULL_MESSAGE(w.resync,
"no resync_state_t must be attached for the Req 1.2 no-op case");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w.host.status),
"XCE_WORKER_RECONCILING must not be set for the Req 1.2 no-op case");
twr_teardown();
printf("test_resync_arm_round_no_jobs_is_noop: PASS\n");
}
/* Req 6.1, 6.3: arming a round for a worker that already carries one
* (simulating a disconnect interrupting an outstanding round, then a
* rejoin) discards the prior round and produces a FRESH, DIFFERENT token --
* and the original token is provably no longer current once superseded. */
static void test_resync_arm_round_fresh_token_supersedes_prior(void)
{
job_t* j;
worker_t w;
int rc;
uint64_t token1;
uint64_t token2;
twr_setup();
j = twr_seed_job(TWR_JOBID_RESYNC_SOLO, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_SOLO,
"resync-host-solo");
memset(&w, 0, sizeof(w));
w.host.name = (char*)"resync-host-solo";
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_NOT_NULL(w.resync);
token1 = w.resync->round_token;
TEST_ASSERT_TRUE_MESSAGE(resync_round_is_current(&w, token1),
"the round just armed must report its own token as current");
/* Simulated disconnect-then-rejoin: the round never resolved, but the
* worker rejoins and is armed again. */
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_NOT_NULL(w.resync);
token2 = w.resync->round_token;
TEST_ASSERT_NOT_EQUAL_MESSAGE(token1, token2,
"re-arming must produce a token distinct from the prior round's "
"(Req 6.1, 6.3)");
TEST_ASSERT_FALSE_MESSAGE(resync_round_is_current(&w, token1),
"the ORIGINAL token must now report stale/superseded (Req 6.3) -- "
"the mechanism a later report-apply caller uses to discard a "
"report carrying it");
TEST_ASSERT_TRUE_MESSAGE(resync_round_is_current(&w, token2),
"the NEW token must report current");
resync_resolve_round(&g_twr, &w);
twr_teardown();
printf("test_resync_arm_round_fresh_token_supersedes_prior: PASS\n");
}
/* Req 5.1, 5.2: XCE_WORKER_RECONCILING is set once a round is armed and
* cleared once resync_resolve_round runs -- resync_round_is_current also
* correctly reports the resolved round as no longer current. */
static void test_resync_reconciling_flag_set_after_arm_cleared_after_resolve(
void)
{
job_t* j;
worker_t w;
int rc;
uint64_t token;
twr_setup();
j = twr_seed_job(TWR_JOBID_RESYNC_SOLO, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_SOLO,
"resync-host-flag");
memset(&w, 0, sizeof(w));
w.host.name = (char*)"resync-host-flag";
TEST_ASSERT_FALSE(IS_WORKER_RECONCILING(w.host.status));
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_TRUE_MESSAGE(IS_WORKER_RECONCILING(w.host.status),
"XCE_WORKER_RECONCILING must be set once armed (Req 5.1)");
token = w.resync->round_token;
resync_resolve_round(&g_twr, &w);
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w.host.status),
"XCE_WORKER_RECONCILING must clear once resolved (Req 5.2)");
TEST_ASSERT_NULL_MESSAGE(w.resync,
"resync_resolve_round must detach/free w->resync");
TEST_ASSERT_FALSE_MESSAGE(resync_round_is_current(&w, token),
"a resolved round's token must no longer report current");
/* Idempotent: resolving an already-resolved (NULL) round is a safe
* no-op, never a double-free. */
resync_resolve_round(&g_twr, &w);
TEST_ASSERT_NULL(w.resync);
twr_teardown();
printf("test_resync_reconciling_flag_set_after_arm_cleared_after_"
"resolve: PASS\n");
}
/* bug-1841 (new): resync_resolve_round() must notify HOOK_DETAIL_WORKER_
* UPDATE subscribers once RECONCILING actually clears -- otherwise the
* scheduler's own copy of this worker's status (fed purely from whatever
* WORKERS_PUSH it last received; core_events.c's core_upsert_worker does a
* bare `worker->status = w->status` with no other refresh path) stays
* RECONCILING forever, permanently freezing that worker's resInUse
* (sched_reset_worker_/sched_update_account's by-design bug-1799/1801
* freeze) even after every job on it has long since finished DONE --
* live-observed via a real scheduler-replay capture: a 22-slot worker
* stuck at used=22.00 for the rest of the run with zero RUN jobs left.
* Mirrors bug-1679's identical job-side gap and its
* test_reconcile_pending_applies_releases_allocs_and_notifies spy-hook
* proof above. */
static int g_twr_worker_update_hook_fires;
static int twr_worker_update_spy_hook(int type, int situation, int detail,
void* data, va_list ap)
{
(void)type; (void)data; (void)ap;
if (situation == HOOK_AFTER_EVENT && detail == HOOK_DETAIL_WORKER_UPDATE)
g_twr_worker_update_hook_fires++;
return 0;
}
static void test_resync_resolve_round_notifies_worker_update(void)
{
job_t* j;
worker_t w;
int rc;
twr_setup();
g_twr_worker_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&g_twr.hooksys, XCE_CONTROLLER_HOOK_WORKER,
twr_worker_update_spy_hook, NULL));
j = twr_seed_job(TWR_JOBID_RESYNC_SOLO, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_SOLO,
"resync-host-notify");
memset(&w, 0, sizeof(w));
w.host.name = (char*)"resync-host-notify";
rc = resync_arm_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w.host.status));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_twr_worker_update_hook_fires,
"arming a round must not itself fire HOOK_DETAIL_WORKER_UPDATE -- "
"only resolving one does");
resync_resolve_round(&g_twr, &w);
TEST_ASSERT_FALSE(IS_WORKER_RECONCILING(w.host.status));
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_worker_update_hook_fires,
"resync_resolve_round must notify HOOK_DETAIL_WORKER_UPDATE "
"subscribers (scheduler/apiserver) once RECONCILING actually "
"clears -- otherwise the scheduler never learns and stays frozen");
/* Idempotent no-op: resolving an already-resolved round must NOT fire a
* second, spurious notification. */
resync_resolve_round(&g_twr, &w);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_worker_update_hook_fires,
"resolving an already-resolved (NULL) round must not re-notify");
twr_teardown();
printf("test_resync_resolve_round_notifies_worker_update: PASS\n");
}
/* Req 5.3: the timeout resolves an outstanding round -- and does so via a
* plain, synchronous function call with no sleep/wait of any kind, proving
* the resolution itself never blocks (ha-async-io.md's delaymgr
* thread-role contract). g_twr never starts a real delaymgr (matching
* every other bare-cluster_t fixture in this file), so resync_arm_round's
* own `!c->delaymgr.running` guard already took the no-real-timer branch
* when the round above was armed; resync_test_fire_timeout below drives
* the EXACT SAME internal resolution logic
* (resync_on_timeout) a real timer firing would have. */
static void test_resync_timeout_resolves_round_without_blocking(void)
{
job_t* j;
worker_t* w;
int rc;
uint64_t token;
twr_setup();
j = twr_seed_job(TWR_JOBID_RESYNC_TIMEOUT, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_RESYNC_TIMEOUT,
"resync-host-timeout");
w = twr_make_test_worker("resync-host-timeout");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_NOT_NULL(w->resync);
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w->host.status));
token = w->resync->round_token;
/* resync_on_timeout (invoked via resync_test_fire_timeout) locks/
* unlocks `w` itself -- this call must NOT be wrapped in an external
* lock(w, ...), or it would deadlock against `w`'s own non-recursive
* write lock. */
resync_test_fire_timeout(&g_twr, w, token);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the timeout must resolve (free/detach) the round");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"the timeout must clear XCE_WORKER_RECONCILING so the worker is "
"not withheld from dispatch indefinitely (Req 5.1, 5.2, 5.3)");
/* A second, late fire for the SAME (now-stale) token must be a safe
* no-op -- never a double-free/double-clear against an already-
* resolved round. This is exactly the round-token staleness check
* (Req 6.3) protecting against a real delaymgr task that could not be
* dequeued/cancelled (delay.c exposes no cancel primitive) firing late
* against a resync_state_t that is already gone or has already been
* replaced by a newer round. */
resync_test_fire_timeout(&g_twr, w, token);
TEST_ASSERT_NULL(w->resync);
TEST_ASSERT_FALSE(IS_WORKER_RECONCILING(w->host.status));
twr_teardown();
printf("test_resync_timeout_resolves_round_without_blocking: PASS\n");
}
/* ── resync_apply_report() tests (task 3.2) ──────────────────────────────
*
* Proves resync_apply_report() (resync.c) -- the report-apply glue wiring
* together task 2.1's record_job_reconcile_pending_fact, task 2.2's
* record_job_reconcile_assign_fact, and task 3.1's resync_arm_round/
* resync_resolve_round/resync_round_is_current. Every test here uses a real
* RTTI-managed worker_t (via twr_make_test_worker), since resync_apply_
* report locks/unlocks `w` itself (unlike resync_arm_round/resync_resolve_
* round/resync_round_is_current above, which require the caller to
* pre-hold `w`'s lock and so tolerate a bare stack struct). */
#define TWR_JOBID_REPORT_FINOK 81108ULL
#define TWR_JOBID_REPORT_FINFAIL 81109ULL
#define TWR_JOBID_REPORT_RUNNING 81110ULL
#define TWR_JOBID_REPORT_UNREC 81111ULL
#define TWR_JOBID_REPORT_EXTRA 81112ULL
#define TWR_JOBID_REPORT_DUP_A 81113ULL
#define TWR_JOBID_REPORT_DUP_B 81114ULL
#define TWR_JOBID_REPORT_STALE 81115ULL
#define TWR_JOBID_REPORT_PARTIAL_A 81116ULL
#define TWR_JOBID_REPORT_PARTIAL_B 81117ULL
#define TWR_JOBID_REPORT_EXTRANOOP_Q 81118ULL
#define TWR_JOBID_REPORT_EXTRANOOP_E 81119ULL
#define TWR_JOBID_REPORT_FINISHTIME_REPLAY 81120ULL
#define TWR_JOBID_REPORT_LOSTDISPATCH_Q 81121ULL
#define TWR_JOBID_REPORT_LOSTDISPATCH 81122ULL
/* THE core proof (Req 4.1-4.5, 6.1): a single report covering all five
* distinct outcome kinds in one pass -- finished-success, finished-failure,
* still-running (queried), unrecognized (queried), and an unqueried extra --
* leaves the job table in the correct state for each, routed through the
* correct existing/new fact function, and resolves the round (outstanding
* set empties -> XCE_WORKER_RECONCILING clears) once every QUERIED entry
* has been answered.
*
* The unqueried-extra entry here is seeded TERMINAL (DONE), exercising
* resync_apply_running_entry()'s branch (a) -- ALWAYS a no-op, regardless
* of host (Req 4.4: a terminal job is never assign-eligible). This is
* deliberate, not an oversight: round-2 review caught that this test
* ORIGINALLY seeded j_extra TERMINAL and then asserted it became RUN --
* i.e. it baked in, and certified as PASSING, the exact Req 4.4 regression
* a since-corrected two-way guard introduced (calling record_job_
* reconcile_assign_fact() on an already-terminal job, unconditionally
* clobbering DONE/EXIT back to RUN). Branch (c) -- non-terminal AND
* genuinely unassigned, the real "gets assigned" case -- is proven
* separately, with full restart-replay rigor, by test_apply_report_
* unqueried_extra_unassigned_pend_job_gets_assigned below; branch (b) --
* non-terminal but already assigned (to this host or elsewhere) -- by
* test_apply_report_unqueried_extra_already_tracked_is_noop. */
static void test_apply_report_all_five_outcomes_updates_job_table(void)
{
job_t* j_finok;
job_t* j_finfail;
job_t* j_running;
job_t* j_unrec;
job_t* j_extra;
worker_t* w;
int rc;
uint64_t token;
uint64_t lsn_before, lsn_after;
xce_job_state_entry_t e_finok, e_finfail, e_running, e_unrec, e_extra;
xce_job_state_entry_t* entries[5];
xce_job_state_report_t report;
twr_setup();
j_finok = twr_seed_job(TWR_JOBID_REPORT_FINOK, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_finok);
j_finok->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_FINOK, "resync-report-host");
j_finfail = twr_seed_job(TWR_JOBID_REPORT_FINFAIL, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_finfail);
j_finfail->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_FINFAIL, "resync-report-host");
j_running = twr_seed_job(TWR_JOBID_REPORT_RUNNING, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_running);
j_running->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_RUNNING, "resync-report-host");
j_unrec = twr_seed_job(TWR_JOBID_REPORT_UNREC, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_unrec);
j_unrec->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_UNREC, "resync-report-host");
/* Seeded TERMINAL (DONE), so it is NOT part of the round's query set
* (arm_round only collects non-terminal jobs), AND -- per Req 4.4 --
* must be left COMPLETELY untouched when later reported running as an
* unqueried extra: a terminal record is never assign-eligible, full
* stop, regardless of host (resync_apply_running_entry()'s branch (a),
* resync.c). */
j_extra = twr_seed_job(TWR_JOBID_REPORT_EXTRA, JOB_STAT_DONE);
TEST_ASSERT_NOT_NULL(j_extra);
TEST_ASSERT_NULL(j_extra->job.allocs);
w = twr_make_test_worker("resync-report-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT_MESSAGE(4, rc,
"exactly the 4 non-terminal jobs on this host must be armed");
TEST_ASSERT_NOT_NULL(w->resync);
token = w->resync->round_token;
e_finok = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_FINOK, .status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 1000, .queried = 1
};
e_finfail = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_FINFAIL,
.status = JobStateStatus_finishedFail,
.exit_code = 7, .finish_time_ms = 2000, .queried = 1
};
e_running = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_RUNNING, .status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
e_unrec = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_UNREC, .status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
e_extra = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_EXTRA, .status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 0
};
entries[0] = &e_finok;
entries[1] = &e_finfail;
entries[2] = &e_running;
entries[3] = &e_unrec;
entries[4] = &e_extra;
report = (xce_job_state_report_t){
.n_total = 5, .pos = 0, .count = 5, .entries = entries
};
lsn_before = twr_current_lsn();
rc = resync_apply_report(&g_twr, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
lsn_after = twr_current_lsn();
/* record_job_finish_fact_at() (bug-1747 fix)/record_job_fail_fact
* (called via on_job_fail, unchanged -- Req 4.1) follow the SAME
* durable-append-only, DEFERRED pattern as any ordinary worker
* finish/fail report: the in-memory state mutation happens later, when
* apply_committed_entry() replays the committed fact -- NOT
* synchronously here (unlike record_job_reconcile_pending_fact/assign_
* fact's own EAGER pattern below, asserted synchronously exactly
* because task 2.1/2.2 built those two functions to mutate eagerly).
* This exact deferred behavior is already proven end-to-end (including
* via a real restart replay) by test_wal_job_finish_fact.c's own
* test_wjf_real_on_job_finish_survives_restart -- this task's own job
* is only to prove routing (the correct fact TYPE, with the correct
* worker-reported finish_time_ms for finishedOk, was durably appended
* through the correct call), not to re-prove that already-tested apply
* seam; test_apply_report_finished_success_uses_worker_reported_
* finish_time below proves the finish_time_ms plumbing specifically,
* via a real restart replay. */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_finok->job.state,
"finished-success routes through the DEFERRED record_job_finish_"
"fact pattern -- state mutation happens later via apply_committed_"
"entry, not synchronously here");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_finfail->job.state,
"finished-failure routes through the DEFERRED record_job_fail_fact "
"pattern -- state mutation happens later via apply_committed_entry, "
"not synchronously here");
TEST_ASSERT_TRUE_MESSAGE(lsn_after >= lsn_before + 3,
"at least 3 durable facts must be appended for this report: "
"finished-success (WAL_JOB_FINISH), finished-failure (WAL_JOB_"
"FAIL), and unrecognized (WAL_JOB_UPDATE) -- the still-running "
"entry and the TERMINAL unqueried extra (Req 4.4 no-op) record "
"none");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_running->job.state,
"a queried still-running job must be left exactly as-is -- no fact "
"recorded");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, j_unrec->job.state,
"unrecognized must apply through record_job_reconcile_pending_fact "
"(-> PEND)");
TEST_ASSERT_NULL_MESSAGE(j_unrec->job.allocs,
"bug-1631: reconciled-to-pending must release the allocation");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j_extra->job.state,
"Req 4.4: a TERMINAL job reported running as an unqueried extra "
"must be left COMPLETELY untouched -- never routed through "
"record_job_reconcile_assign_fact, regardless of host");
TEST_ASSERT_NULL_MESSAGE(j_extra->job.allocs,
"the terminal extra's allocation (already NULL) must stay "
"untouched too");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"once every queried job (4 of them) has a reconciled outcome, the "
"round must resolve");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
twr_teardown();
printf("test_apply_report_all_five_outcomes_updates_job_table: PASS\n");
}
/* Req 6.2: applying the identical report a second time (same round token)
* must produce the same result as applying it once -- no further job-table
* change, no additional WAL append. Since the first application already
* fully resolves the round (both entries here are queried), the round
* token is no longer current by the time the second, identical call
* arrives -- resync_apply_report's own Req 6.1/6.3 stale-round discard is
* exactly the mechanism that makes the duplicate a pure no-op. */
static void test_apply_report_duplicate_reapplication_is_noop(void)
{
job_t* ja;
job_t* jb;
worker_t* w;
int rc;
uint64_t token;
uint64_t lsn_before_first, lsn_after_first, lsn_after_second;
xce_job_state_entry_t ea, eb;
xce_job_state_entry_t* entries[2];
xce_job_state_report_t report;
twr_setup();
ja = twr_seed_job(TWR_JOBID_REPORT_DUP_A, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(ja);
ja->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_REPORT_DUP_A,
"resync-dup-host");
jb = twr_seed_job(TWR_JOBID_REPORT_DUP_B, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(jb);
jb->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_REPORT_DUP_B,
"resync-dup-host");
w = twr_make_test_worker("resync-dup-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(2, rc);
token = w->resync->round_token;
ea = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_DUP_A, .status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 500, .queried = 1
};
eb = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_DUP_B, .status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
entries[0] = &ea;
entries[1] = &eb;
report = (xce_job_state_report_t){
.n_total = 2, .pos = 0, .count = 2, .entries = entries
};
lsn_before_first = twr_current_lsn();
rc = resync_apply_report(&g_twr, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
/* ea (finishedOk) routes through the DEFERRED record_job_finish_fact_at
* pattern (bug-1747 fix) -- state mutation happens later via
* apply_committed_entry, not synchronously here (see the "all five
* outcomes" test's own comment for the full rationale); ja's state is
* asserted unchanged (still RUN) below instead. */
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, ja->job.state);
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, jb->job.state);
TEST_ASSERT_NULL(jb->job.allocs);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round must resolve after the first, complete application");
lsn_after_first = twr_current_lsn();
TEST_ASSERT_TRUE_MESSAGE(lsn_after_first >= lsn_before_first + 2,
"the first application must durably append a fact for each of the "
"2 queried entries");
/* Second, IDENTICAL application -- same token, same report contents. */
rc = resync_apply_report(&g_twr, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
lsn_after_second = twr_current_lsn();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, ja->job.state,
"a duplicate re-application must change nothing further");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, jb->job.state,
"a duplicate re-application must change nothing further");
TEST_ASSERT_NULL(jb->job.allocs);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_after_first, lsn_after_second,
"a duplicate re-application for an already-resolved round must "
"append no further WAL fact (Req 6.2)");
TEST_ASSERT_NULL(w->resync);
twr_teardown();
printf("test_apply_report_duplicate_reapplication_is_noop: PASS\n");
}
/* Req 6.1, 6.3: a report carrying a round token already superseded by a
* newer round for the SAME worker is dropped in its entirety -- the job
* table must be completely untouched, and the (unrelated, current) newer
* round must be left exactly as it was. */
static void test_apply_report_stale_round_token_is_discarded(void)
{
job_t* j;
worker_t* w;
int rc;
uint64_t token1, token2;
uint64_t lsn_before, lsn_after;
xce_job_state_entry_t e;
xce_job_state_entry_t* entries[1];
xce_job_state_report_t report;
twr_setup();
j = twr_seed_job(TWR_JOBID_REPORT_STALE, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_REPORT_STALE,
"resync-stale-host");
w = twr_make_test_worker("resync-stale-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
token1 = w->resync->round_token;
/* Simulated disconnect-then-rejoin: a fresh round supersedes the first
* before its report ever arrives. */
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
token2 = w->resync->round_token;
TEST_ASSERT_NOT_EQUAL(token1, token2);
e = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_STALE, .status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 999, .queried = 1
};
entries[0] = &e;
report = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries
};
lsn_before = twr_current_lsn();
/* A report carrying the now-stale token1 must be dropped entirely. */
rc = resync_apply_report(&g_twr, w, token1, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
lsn_after = twr_current_lsn();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j->job.state,
"a stale-round report must be dropped without touching the job "
"table");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, lsn_after,
"a stale-round report must append no WAL fact");
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"the CURRENT (newer) round must be left outstanding, untouched by "
"the stale report");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(token2, w->resync->round_token,
"the current round's own token must be unaffected");
TEST_ASSERT_EQUAL_INT(1, w->resync->n_queried_jobids);
resync_resolve_round(&g_twr, w);
twr_teardown();
printf("test_apply_report_stale_round_token_is_discarded: PASS\n");
}
/* Req 5.1, 5.2: a partial report (not all queried jobs answered yet) must
* NOT resolve the round -- the outstanding set stays non-empty and
* XCE_WORKER_RECONCILING stays set -- until a LATER call with the
* remaining entries completes it, proving resolution is genuinely
* incremental against the outstanding set, not "resolve unconditionally
* after any report." */
static void test_apply_report_partial_report_does_not_resolve_round(void)
{
job_t* ja;
job_t* jb;
worker_t* w;
int rc;
uint64_t token;
uint64_t lsn_before, lsn_after;
xce_job_state_entry_t ea, eb;
xce_job_state_entry_t* entries_a[1];
xce_job_state_entry_t* entries_b[1];
xce_job_state_report_t report_a, report_b;
twr_setup();
ja = twr_seed_job(TWR_JOBID_REPORT_PARTIAL_A, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(ja);
ja->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_REPORT_PARTIAL_A,
"resync-partial-host");
jb = twr_seed_job(TWR_JOBID_REPORT_PARTIAL_B, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(jb);
jb->job.allocs = twr_make_test_alloc_for_host(TWR_JOBID_REPORT_PARTIAL_B,
"resync-partial-host");
w = twr_make_test_worker("resync-partial-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(2, rc);
token = w->resync->round_token;
ea = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_PARTIAL_A,
.status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 111, .queried = 1
};
entries_a[0] = &ea;
report_a = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries_a
};
lsn_before = twr_current_lsn();
rc = resync_apply_report(&g_twr, w, token, &report_a);
TEST_ASSERT_EQUAL_INT(0, rc);
lsn_after = twr_current_lsn();
/* ea (finishedOk) routes through the DEFERRED record_job_finish_fact
* pattern (via on_job_finish, unchanged) -- state mutation happens
* later via apply_committed_entry, not synchronously here (see the
* "all five outcomes" test's own comment for the full rationale); a
* durable append is asserted instead. */
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, ja->job.state);
TEST_ASSERT_TRUE_MESSAGE(lsn_after > lsn_before,
"job A's finished-success entry must durably append a fact");
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"job B has not been answered yet -- the round must stay "
"outstanding");
TEST_ASSERT_EQUAL_INT(1, w->resync->n_queried_jobids);
TEST_ASSERT_TRUE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must remain set while the round is still "
"partially outstanding");
eb = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_PARTIAL_B, .status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
entries_b[0] = &eb;
report_b = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries_b
};
rc = resync_apply_report(&g_twr, w, token, &report_b);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_RUN, jb->job.state);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round must resolve once the LAST outstanding queried job "
"(B) is answered");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round fully resolves");
twr_teardown();
printf("test_apply_report_partial_report_does_not_resolve_round: "
"PASS\n");
}
/* Req 3.6/4.7/4.4 (the crux branching this task's own brief flagged as
* needing careful resolution against job.c's record_job_reconcile_assign_
* fact doc comment; round-2 review strengthened this test after finding it
* could not actually distinguish "correctly no-op'd" from "incorrectly
* reassigned" -- see below): an unqueried extra reported RUNNING that the
* controller ALREADY has a non-terminal record of (here: assigned to a
* DIFFERENT host -- resync_apply_running_entry()'s branch (b), non-terminal
* AND job->job.allocs != NULL) must NOT be routed through record_job_
* reconcile_assign_fact -- it is already correctly represented by simply
* existing and being reported running.
*
* Proven three ways, precisely enough to actually catch a regression back
* to either of the two review-caught defects (round 1's Req 4.8-axis bug;
* round 2's Req 4.4-axis bug):
* 1. allocs survives UNTOUCHED -- record_job_reconcile_assign_fact always
* sets job->job.allocs to NULL (Req 4.8), so a surviving allocation
* proves the no-op branch fired instead of the assign branch.
* 2. start_time survives UNTOUCHED at its seeded sentinel value --
* record_job_reconcile_assign_fact ALSO unconditionally stamps
* job->job.start_time = time(NULL) (bug-1697), even though the net
* STATE effect (RUN -> RUN) looks harmless and is invisible to (1) or
* to a bare state-equality assertion; this is exactly what round 1's
* version of this test could not catch, since it never checked
* start_time and the job was already RUN before the call either way.
* 3. no additional WAL fact is appended (LSN unchanged) -- record_job_
* reconcile_assign_fact durably appends a fresh WAL_JOB_DISPATCH fact
* unconditionally; a no-op must append nothing (the paired queried
* "running" entry records nothing either, so the WHOLE report's LSN
* delta must be exactly zero). */
static void test_apply_report_unqueried_extra_already_tracked_is_noop(void)
{
job_t* j_queried;
job_t* j_extra;
worker_t* w;
int rc;
uint64_t token;
uint64_t lsn_before, lsn_after;
time_t seeded_start_time;
xce_job_state_entry_t e_queried, e_extra;
xce_job_state_entry_t* entries[2];
xce_job_state_report_t report;
twr_setup();
/* Keeps the round non-empty (arm_round needs >=1 non-terminal job on
* w's own host to arm at all) -- unrelated to the extra under test. */
j_queried = twr_seed_job(TWR_JOBID_REPORT_EXTRANOOP_Q, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_queried);
j_queried->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_EXTRANOOP_Q, "resync-extranoop-host");
/* Non-terminal (RUN), already assigned -- to a DIFFERENT host, so
* arm_round's own query-set construction (non-terminal jobs assigned
* to w's specific host) never includes it; it arrives only as an
* unqueried extra in the report below. */
j_extra = twr_seed_job(TWR_JOBID_REPORT_EXTRANOOP_E, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_extra);
j_extra->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_EXTRANOOP_E, "resync-extranoop-OTHER-host");
/* A deliberately-distinguishable sentinel, far from "now" -- so a
* wrongful record_job_reconcile_assign_fact() call (which stamps
* time(NULL)) is caught precisely, not masked by coincidence. */
seeded_start_time = (time_t)424242;
j_extra->job.start_time = seeded_start_time;
w = twr_make_test_worker("resync-extranoop-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"only j_queried is assigned to w's own host");
token = w->resync->round_token;
e_queried = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_EXTRANOOP_Q,
.status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
e_extra = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_EXTRANOOP_E,
.status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 0
};
entries[0] = &e_queried;
entries[1] = &e_extra;
report = (xce_job_state_report_t){
.n_total = 2, .pos = 0, .count = 2, .entries = entries
};
lsn_before = twr_current_lsn();
rc = resync_apply_report(&g_twr, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
lsn_after = twr_current_lsn();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_extra->job.state,
"an already-non-terminal extra must be left exactly as-is");
TEST_ASSERT_NOT_NULL_MESSAGE(j_extra->job.allocs,
"record_job_reconcile_assign_fact must NOT have been called -- its "
"own contract always sets allocs to NULL, so a surviving "
"allocation proves the no-op branch fired instead");
TEST_ASSERT_EQUAL_UINT64_MESSAGE((uint64_t)seeded_start_time,
(uint64_t)j_extra->job.start_time,
"record_job_reconcile_assign_fact must NOT have been called -- it "
"unconditionally stamps start_time = time(NULL) (bug-1697), so a "
"start_time still equal to the seeded sentinel (not \"now\") "
"proves the no-op branch fired, catching what a bare state/allocs "
"check alone cannot (round-2 review finding)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, lsn_after,
"neither entry in this report may append a WAL fact: the queried "
"running entry never does, and the already-tracked unqueried "
"extra must not durably re-append a WAL_JOB_DISPATCH fact for a "
"job that was never actually lost/unassigned");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round's only queried job (j_queried) was answered -- it must "
"resolve regardless of the unqueried extra");
twr_teardown();
printf("test_apply_report_unqueried_extra_already_tracked_is_noop: "
"PASS\n");
}
/* bug-1747 fix (Req 4.2): a finished-success reconciliation entry's
* WORKER-reported finish_time_ms must end up as the job's recorded
* finish_time after a real restart replay -- NOT reconciliation-processing
* wall-clock time. Mirrors test_reconcile_assign_survives_restart_replay's
* own Phase 1 (real cluster + disk persist, drive the real function under
* test, flush) / Phase 2 (fresh persist_t + persist_recover(), assert on
* the recovered job_t) shape, and test_wal_job_finish_fact.c's own
* test_wjf_real_on_job_finish_survives_restart precedent for proving a
* finish_time round-trip through a real WAL append + replay, not a
* hand-built payload. Uses its own local cluster_t/persist_t/tmpdir (Phase
* 1's disk state must survive past Phase 1's own teardown) -- only
* g_twr.workermgr (via twr_make_test_worker) is shared with the rest of
* this file's fixture, exactly as test_reconcile_assign_survives_restart_
* replay already establishes is safe (resync_apply_report's own locking
* only ever touches `w` and the `c` passed to it, never workermgr). */
static void
test_apply_report_finished_success_uses_worker_reported_finish_time(void)
{
char tmp[256];
uint64_t worker_reported_finish_time_s;
strcpy(tmp, "/tmp/xce_twr24_finishtime_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
twr_setup();
/* A finish time deliberately, clearly DIFFERENT from "now" -- one full
* day in the past -- so a reconciliation-processing-time bug
* (time(NULL)) cannot accidentally produce a matching value. */
worker_reported_finish_time_s = (uint64_t)time(NULL) - 86400;
/* Phase 1: real cluster + real disk persist. Seed a durable
* WAL_JOB_SUBMIT fact directly (so replay can reconstruct the job --
* this job was never dispatched through any controller in this log)
* and the in-memory job_t the same way (bypassing dispatch/replay),
* already RUN and assigned to the reconciling worker's host, then
* drive a real resync_apply_report() call carrying the worker-reported
* finish_time_ms. */
{
cluster_t c;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
job_t* j;
worker_t* w;
int rc;
uint64_t token;
xce_job_state_entry_t e;
xce_job_state_entry_t* entries[1];
xce_job_state_report_t report;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
{
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_REPORT_FINISHTIME_REPLAY;
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
}
{
job_submit_data_t submit;
xce_job_t jdef;
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_REPORT_FINISHTIME_REPLAY;
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
j = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(j);
}
j->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_FINISHTIME_REPLAY, "resync-finishtime-host");
w = twr_make_test_worker("resync-finishtime-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&c, w);
TEST_ASSERT_EQUAL_INT(1, rc);
token = w->resync->round_token;
e = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_FINISHTIME_REPLAY,
.status = JobStateStatus_finishedOk,
.exit_code = 0,
.finish_time_ms = worker_reported_finish_time_s * 1000ULL,
.queried = 1
};
entries[0] = &e;
report = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries
};
rc = resync_apply_report(&c, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round's only queried job was answered -- it must resolve");
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
}
/* Phase 2: fresh persist_t + fresh cluster_t (jobmgr/hooksys only),
* recover from disk -- simulates a controller restart. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2 = NULL;
int rc;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_REPORT_FINISHTIME_REPLAY,
(void**)&j2);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL_MESSAGE(j2,
"restart replay must reconstruct the job via WAL_JOB_SUBMIT");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j2->job.state,
"the finished-success fact must apply to DONE on replay");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(worker_reported_finish_time_s,
(uint64_t)j2->job.finish_time,
"bug-1747 (Req 4.2): the recovered finish_time must match the "
"WORKER-reported finish_time_ms, not reconciliation-processing "
"wall-clock time");
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
twr_teardown();
printf("test_apply_report_finished_success_uses_worker_reported_"
"finish_time: PASS\n");
}
/* Review-caught regression fix (Req 4.8): a job the controller currently
* shows PEND (non-terminal, allocs == NULL, unassigned to any host) --
* exactly design.md's own "Documented scope decision" scenario, e.g. its
* WAL_JOB_DISPATCH fact never reached durable majority commit before a
* controller failover, even though dispatch_a_job_() already sent
* xce_startjob_req() to the worker and the worker really is running it --
* must NOT be left stuck at PEND when reported running as an unqueried
* extra. It must reconcile via record_job_reconcile_assign_fact() to RUN,
* matching the reporting worker's host, with allocs staying NULL (there is
* none to fabricate, Req 4.8's own documented scope decision). Mirrors
* test_reconcile_assign_survives_restart_replay's rigor: real WAL_JOB_
* SUBMIT seed + real resync_apply_report() call + real WAL flush, followed
* by a genuine persist_recover() restart replay -- not just an in-memory
* assertion -- so this proves the fix survives a real controller restart,
* not merely the live eager-mutation path. A second, unrelated queried job
* on the same host keeps the round non-empty (arm_round's own query set
* construction only ever finds jobs it already believes are non-terminal
* AND assigned to the host -- by definition NOT this PEND/unassigned job,
* which is exactly why it only ever arrives as an unqueried extra). */
static void
test_apply_report_unqueried_extra_unassigned_pend_job_gets_assigned(void)
{
char tmp[256];
strcpy(tmp, "/tmp/xce_twr25_lostdispatch_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
twr_setup();
/* Phase 1: real cluster + real disk persist. */
{
cluster_t c;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
job_t* jq;
job_t* jlost;
worker_t* w;
int rc;
uint64_t token;
xce_job_state_entry_t eq, elost;
xce_job_state_entry_t* entries[2];
xce_job_state_report_t report;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
/* Durable WAL_JOB_SUBMIT seeds -- jq at RUN (assigned, keeps the
* round non-empty), jlost at PEND (unassigned -- the job whose
* WAL_JOB_DISPATCH fact never durably committed). */
{
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_REPORT_LOSTDISPATCH_Q;
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_REPORT_LOSTDISPATCH;
wal_jdef.state = JOB_STAT_PEND;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
}
/* Seed the in-memory job_t's the same way, bypassing dispatch/
* replay. */
{
job_submit_data_t submit;
xce_job_t jdef;
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_REPORT_LOSTDISPATCH_Q;
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
jq = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(jq);
jq->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_REPORT_LOSTDISPATCH_Q, "resync-lostdispatch-host");
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_REPORT_LOSTDISPATCH;
jdef.state = JOB_STAT_PEND;
jdef.submit = &submit;
jlost = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(jlost);
TEST_ASSERT_NULL_MESSAGE(jlost->job.allocs,
"the lost-dispatch-fact job starts PEND and unassigned -- "
"no allocation was ever durably recorded for it");
}
w = twr_make_test_worker("resync-lostdispatch-host");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&c, w);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"only jq (RUN, assigned) is in the controller's own query set "
"-- jlost (PEND, unassigned) is invisible to arm_round's own "
"scan, exactly why it can only ever arrive as an unqueried "
"extra");
token = w->resync->round_token;
eq = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_LOSTDISPATCH_Q,
.status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
elost = (xce_job_state_entry_t){
.jobid = TWR_JOBID_REPORT_LOSTDISPATCH,
.status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 0
};
entries[0] = &eq;
entries[1] = &elost;
report = (xce_job_state_report_t){
.n_total = 2, .pos = 0, .count = 2, .entries = entries
};
rc = resync_apply_report(&c, w, token, &report);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Eager pattern (record_job_reconcile_assign_fact) -- observable
* synchronously, before any commit round-trip. */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, jlost->job.state,
"a PEND, unassigned job reported running as an unqueried extra "
"must reconcile to RUN via record_job_reconcile_assign_fact -- "
"not be left stuck at PEND");
TEST_ASSERT_NULL_MESSAGE(jlost->job.allocs,
"Req 4.8: no allocation is fabricated for a reconciliation-"
"assigned job");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round's only QUERIED job (jq) was answered -- it must "
"resolve regardless of the unqueried extra");
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
}
/* Phase 2: fresh persist_t + fresh cluster_t, recover from disk --
* proves the fix survives a real controller restart, not just the live
* eager-mutation path. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2 = NULL;
int rc;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_REPORT_LOSTDISPATCH,
(void**)&j2);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL_MESSAGE(j2,
"restart replay must reconstruct the job via WAL_JOB_SUBMIT");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j2->job.state,
"restart replay must recover the job as RUNNING from the "
"WAL_JOB_DISPATCH fact appended by record_job_reconcile_"
"assign_fact()'s real call -- the fix must survive a real "
"controller restart, not just the live eager-mutation path");
TEST_ASSERT_NULL_MESSAGE(j2->job.allocs,
"Req 4.8: replay must NOT fabricate a resource allocation for "
"a reconciliation-assigned job");
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
twr_teardown();
printf("test_apply_report_unqueried_extra_unassigned_pend_job_gets_"
"assigned: PASS\n");
}
/* ══════════════════════════════════════════════════════════════════════
* Task 6.2 (Req 8.1, 8.3): resync_handle_report_page() -- multi-page wire
* reassembly of an incoming OPCode_jobStateReport, ahead of resync_apply_
* report() (task 3.2, proven above). Every test here proves the exact
* gap this task's own brief called out: not just that the correct END
* STATE results, but that resync_apply_report() is invoked EXACTLY ONCE
* per report regardless of page count (resync_apply_report_call_count_
* for_test(), resync.h) -- job.c's own idempotent fact guards would make
* even a buggy once-per-page implementation LOOK correct on a happy-path
* report, exactly the class of bug task 4.3's own round-1 review missed
* with a purely behavioral test (tasks.md Implementation Notes).
* ══════════════════════════════════════════════════════════════════════ */
#define TWR_JOBID_MPAGE_FINOK 81201ULL
#define TWR_JOBID_MPAGE_UNREC 81202ULL
#define TWR_JOBID_MPAGE_RUNNING 81203ULL
#define TWR_JOBID_MPAGE_STALE 81210ULL
#define TWR_JOBID_MPAGE_BAD 81220ULL
#define TWR_JOBID_MPAGE_BAD2 81221ULL
/* Baseline: a single-page report (pos=0, count==n_total) delivered through
* resync_handle_report_page() -- not resync_apply_report() directly -- is
* reassembled trivially (one page IS the whole report) and applied exactly
* once. Establishes the expected end state the multi-page test below must
* independently reproduce via genuine reassembly. */
static void test_handle_report_page_single_page_baseline(void)
{
job_t* j_unrec;
worker_t* w;
int rc;
uint64_t token;
int calls_before, calls_after;
xce_job_state_entry_t e_unrec;
xce_job_state_entry_t* entries[1];
xce_job_state_report_t page;
twr_setup();
j_unrec = twr_seed_job(TWR_JOBID_MPAGE_UNREC, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_unrec);
j_unrec->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_MPAGE_UNREC, "mpage-host-1");
w = twr_make_test_worker("mpage-host-1");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
token = w->resync->round_token;
e_unrec = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_UNREC, .status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
entries[0] = &e_unrec;
page = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries
};
resync_apply_report_call_count_reset_for_test();
calls_before = resync_apply_report_call_count_for_test();
rc = resync_handle_report_page(&g_twr, w, &page);
calls_after = resync_apply_report_call_count_for_test();
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"a single page whose count == n_total completes the report "
"immediately");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, calls_after - calls_before,
"resync_apply_report must be invoked exactly once");
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, j_unrec->job.state);
TEST_ASSERT_NULL(j_unrec->job.allocs);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round must resolve once its only queried job is answered");
(void)token;
twr_teardown();
printf("test_handle_report_page_single_page_baseline: PASS\n");
}
/* THE core proof (task 6.2's own Observable Completion Criterion): the SAME
* report, split across two wire pages, must (a) leave the job table
* COMPLETELY untouched after the first (non-final) page -- no partial
* application -- (b) converge to EXACTLY the same end state the single-page
* baseline above reached, once the final page arrives, and (c) invoke
* resync_apply_report() EXACTLY ONCE for the whole report, not once per
* page (the structural proof a behavioral-only assertion cannot provide --
* see this section's own header comment). */
static void test_handle_report_page_multi_page_reassembles_and_applies_once(
void)
{
job_t* j_finok;
job_t* j_unrec;
job_t* j_running;
worker_t* w;
int rc;
int calls_before, calls_mid, calls_after;
xce_job_state_entry_t e_finok, e_unrec, e_running;
xce_job_state_entry_t* entries_p1[2];
xce_job_state_entry_t* entries_p2[1];
xce_job_state_report_t page1, page2;
twr_setup();
j_finok = twr_seed_job(TWR_JOBID_MPAGE_FINOK, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_finok);
j_finok->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_MPAGE_FINOK, "mpage-host-2");
j_unrec = twr_seed_job(TWR_JOBID_MPAGE_UNREC, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_unrec);
j_unrec->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_MPAGE_UNREC, "mpage-host-2");
j_running = twr_seed_job(TWR_JOBID_MPAGE_RUNNING, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j_running);
j_running->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_MPAGE_RUNNING, "mpage-host-2");
w = twr_make_test_worker("mpage-host-2");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT_MESSAGE(3, rc,
"all 3 non-terminal jobs on this host must be armed");
e_finok = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_FINOK, .status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 4242, .queried = 1
};
e_unrec = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_UNREC, .status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
e_running = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_RUNNING, .status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
/* Page 1 of 2: entries [0..1] (finok, unrec). Page 2 of 2: entry [2]
* (running). Mirrors xce_job_state_report_req()'s own {pos,count,
* n_total} pagination shape (request.c) -- this is exactly what a real
* multi-page OPCode_jobStateReport looks like on the wire. */
entries_p1[0] = &e_finok;
entries_p1[1] = &e_unrec;
page1 = (xce_job_state_report_t){
.n_total = 3, .pos = 0, .count = 2, .entries = entries_p1
};
entries_p2[0] = &e_running;
page2 = (xce_job_state_report_t){
.n_total = 3, .pos = 2, .count = 1, .entries = entries_p2
};
resync_apply_report_call_count_reset_for_test();
calls_before = resync_apply_report_call_count_for_test();
rc = resync_handle_report_page(&g_twr, w, &page1);
calls_mid = resync_apply_report_call_count_for_test();
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"page 1 of 2 alone must NOT complete the report");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, calls_mid - calls_before,
"resync_apply_report must NOT be called after a non-final page -- "
"the structural 'not once per page' proof");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_finok->job.state,
"no partial application: job A must be untouched after page 1");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_unrec->job.state,
"no partial application: job B must be untouched after page 1 -- "
"still RUN, NOT yet reconciled to PEND");
TEST_ASSERT_NOT_NULL_MESSAGE(j_unrec->job.allocs,
"no partial application: job B's allocation must not be released "
"until the report is fully assembled and applied");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_running->job.state,
"job C (only referenced in page 2, not yet arrived) is untouched");
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"the round must still be outstanding after only a partial report");
rc = resync_handle_report_page(&g_twr, w, &page2);
calls_after = resync_apply_report_call_count_for_test();
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"page 2 of 2 completes the report");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, calls_after - calls_mid,
"resync_apply_report must be invoked EXACTLY ONCE, on the final "
"page, for the whole 3-entry report -- not once per page");
/* Equivalence (task 6.2's own Observable Completion Criterion): the SAME
* end state test_handle_report_page_single_page_baseline reached for an
* equivalent single-page report. */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_finok->job.state,
"finished-success stays in the DEFERRED record_job_finish_fact_at "
"pattern -- state mutates later via apply_committed_entry, same as "
"an equivalent single-page report (test_apply_report_all_five_"
"outcomes_updates_job_table proves this deferred path directly)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, j_unrec->job.state,
"job B reconciles to PEND -- identical to the single-page baseline");
TEST_ASSERT_NULL_MESSAGE(j_unrec->job.allocs,
"job B's allocation is released -- identical to the single-page "
"baseline");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j_running->job.state,
"job C (queried running) is left exactly as-is -- no fact "
"recorded, identical to the single-page baseline");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"once every queried job across BOTH pages has a reconciled "
"outcome, the round must resolve -- identical to the single-page "
"baseline");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
twr_teardown();
printf("test_handle_report_page_multi_page_reassembles_and_applies_once:"
" PASS\n");
}
/* Req 6.1/6.3 at the reassembly layer: a report that STARTS arriving under
* one round but finishes assembling only after that round has been
* superseded by a fresh resync_arm_round() call (simulating a disconnect-
* then-immediate-rejoin racing the tail of a still-in-flight report) must
* be discarded in its entirety -- not matched against whichever round
* happens to be current once the LAST page arrives. Proves the round_token
* CAPTURE point (page 1, resync_report_accum_t.round_token) is what makes
* this correct, per resync.h's own doc comment on why that capture is not
* an arbitrary choice. */
static void test_handle_report_page_stale_round_discarded_when_superseded(
void)
{
job_t* j;
worker_t* w;
int rc;
int calls_before, calls_after;
xce_job_state_entry_t e1, e2;
xce_job_state_entry_t* entries_p1[1];
xce_job_state_entry_t* entries_p2[1];
xce_job_state_report_t page1, page2;
twr_setup();
j = twr_seed_job(TWR_JOBID_MPAGE_STALE, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_MPAGE_STALE, "mpage-host-3");
w = twr_make_test_worker("mpage-host-3");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
e1 = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_STALE, .status = JobStateStatus_finishedOk,
.exit_code = 0, .finish_time_ms = 1, .queried = 1
};
entries_p1[0] = &e1;
page1 = (xce_job_state_report_t){
.n_total = 2, .pos = 0, .count = 1, .entries = entries_p1
};
rc = resync_handle_report_page(&g_twr, w, &page1);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Simulated disconnect-then-rejoin racing the tail of the in-flight
* report above: a fresh round supersedes the one page1 started under,
* BEFORE page 2 ever arrives. */
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_NOT_NULL(w->resync);
/* A second entry, unrelated jobid, just to complete the (now-stale)
* report's page count -- its content is immaterial since the whole
* report must be discarded regardless. */
e2 = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_STALE, .status = JobStateStatus_finishedFail,
.exit_code = 0, .finish_time_ms = 2, .queried = 1
};
entries_p2[0] = &e2;
page2 = (xce_job_state_report_t){
.n_total = 2, .pos = 1, .count = 1, .entries = entries_p2
};
resync_apply_report_call_count_reset_for_test();
calls_before = resync_apply_report_call_count_for_test();
rc = resync_handle_report_page(&g_twr, w, &page2);
calls_after = resync_apply_report_call_count_for_test();
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"the report completes (page 2 arrived) and IS handed to "
"resync_apply_report -- which then discards it internally as "
"stale, per its own Req 6.1/6.3 contract");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, calls_after - calls_before,
"resync_apply_report is still called exactly once -- discarding "
"happens INSIDE it (round-token mismatch), not by skipping the "
"call");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j->job.state,
"the stale report must never touch the job table");
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"the CURRENT (superseding) round must be left outstanding, "
"untouched by the stale report");
TEST_ASSERT_EQUAL_INT(1, w->resync->n_queried_jobids);
resync_resolve_round(&g_twr, w);
twr_teardown();
printf("test_handle_report_page_stale_round_discarded_when_superseded:"
" PASS\n");
}
/* Robustness: a page whose [pos, pos+count) range falls outside the
* report's own declared n_total is rejected (-EINVAL) and discards
* whatever partial accumulation was in progress -- proven by immediately
* starting a FRESH, valid report afterward and confirming it applies
* correctly, i.e. the corrupt attempt left no residue behind to corrupt a
* later, legitimate report on the same worker. */
static void test_handle_report_page_malformed_bounds_rejected_and_recovers(
void)
{
job_t* j;
worker_t* w;
int rc;
xce_job_state_entry_t e_bad, e_good;
xce_job_state_entry_t* entries_bad[1];
xce_job_state_entry_t* entries_good[1];
xce_job_state_report_t page_bad, page_good;
twr_setup();
j = twr_seed_job(TWR_JOBID_MPAGE_BAD, JOB_STAT_RUN);
TEST_ASSERT_NOT_NULL(j);
j->job.allocs
= twr_make_test_alloc_for_host(TWR_JOBID_MPAGE_BAD, "mpage-host-4");
w = twr_make_test_worker("mpage-host-4");
TEST_ASSERT_NOT_NULL(w);
rc = resync_arm_round(&g_twr, w);
TEST_ASSERT_EQUAL_INT(1, rc);
/* pos(1) + count(1) == 2 > n_total(1): out of bounds. */
e_bad = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_BAD2, .status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 0
};
entries_bad[0] = &e_bad;
page_bad = (xce_job_state_report_t){
.n_total = 1, .pos = 1, .count = 1, .entries = entries_bad
};
rc = resync_handle_report_page(&g_twr, w, &page_bad);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EINVAL, rc,
"an out-of-bounds page must be rejected");
/* A fresh, valid, single-page report for the SAME worker right after --
* proves the malformed attempt above left no corrupted accumulator
* state behind. */
e_good = (xce_job_state_entry_t){
.jobid = TWR_JOBID_MPAGE_BAD, .status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
entries_good[0] = &e_good;
page_good = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries_good
};
rc = resync_handle_report_page(&g_twr, w, &page_good);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"a subsequent valid report must apply normally");
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_PEND, j->job.state);
TEST_ASSERT_NULL(j->job.allocs);
TEST_ASSERT_NULL(w->resync);
twr_teardown();
printf("test_handle_report_page_malformed_bounds_rejected_and_recovers:"
" PASS\n");
}
/* ══════════════════════════════════════════════════════════════════════
* Task 7.1 (Req 1.1-1.3, 2.1-2.2, 3.1-3.3, 3.6, 4.1-4.8, 7.1-7.3): full
* rejoin-reconciliation round trip against a live controller+worker pair.
*
* design.md's own Testing Strategy files this under "Integration Tests" as
* "against a live controller+worker pair, mirroring test_worker_join.c's
* fixture conventions (setup_test_cluster/teardown_test_cluster, direct
* calls, one test per behavior)" -- NOT separate OS processes or real TCP
* daemons. test_jobstatereport_dispatch.c's own header comment (task 6.2)
* additionally confirms a genuine wire dispatch through myreq_handler /
* handle_jobstatereport() cannot resolve a REAL worker_t in THIS binary at
* all: get_ep_worker() (misc.c) is not linked into controller_unit_test --
* a fixed test double defined in test_commit_barrier_reply.c stands in for
* it, always returning NULL/a fixture-controlled stub, for every test file
* in this binary, this one included. So this test drives the exact same
* REAL production entry points test_worker_join.c's own task 6.1 tests use
* directly -- rejoin_worker() (the real join path) to arm the round and
* worker_send_resync_query() to send the real wire query -- then feeds a
* REAL wire-encoded-then-decoded OPCode_jobStateReport (via task 1.4's own
* xce_job_state_report_req()/xce_decode() codec) into resync_handle_report_
* page() (task 6.2's own dispatch/reassembly entry point -- never resync_
* apply_report() directly), exactly mirroring how a real worker's answer
* would arrive and be reassembled/dispatched, without needing get_ep_
* worker()'s (unavailable) wire-to-worker_t resolution.
*
* Proves COMPOSITION, not per-outcome correctness -- each individual outcome
* (still-running no-op, finished-success, finished-failure, unrecognized)
* is already exhaustively unit-tested in isolation by tasks 2.1/2.2/3.2 (see
* test_apply_report_all_five_outcomes_updates_job_table above, and task
* 6.1's own test_worker_join.c tests for arm+query-send in isolation). This
* test's own value-add is proving all of join -> arm -> real wire query ->
* real wire report -> dispatch/reassembly -> apply -> scheduler-notify
* compose correctly TOGETHER, for a MIX of four different job outcomes, in
* ONE round, in ONE test run -- and that the scheduler notify path is
* reached for every actual state change produced, across BOTH of this
* feature's two distinct notify patterns: the EAGER path (unrecognized ->
* pending, task 2.1's pattern -- notified synchronously within the same
* call, asserted in Phase 1 below) and the DEFERRED path (finished-success/
* failure, task 3.2's pattern -- notified only once the durable fact is
* later applied). This bare-persist RETAIN-mode fixture has no live commit-
* engine apply pump to do that second half automatically, so this test
* proves it the same way test_apply_report_finished_success_uses_worker_
* reported_finish_time and test_reconcile_assign_survives_restart_replay
* already establish as this file's own precedent for that exact situation:
* a genuine two-phase restart-replay (Phase 1: real cluster + disk persist,
* drive the real chain, flush, close; Phase 2: fresh persist_t + fresh
* cluster_t, persist_recover() -- the SAME apply_committed_entry() seam a
* live controller's commit-apply pump would invoke). */
#define TWR_JOBID_RT_RUNNING 81301ULL
#define TWR_JOBID_RT_FINOK 81302ULL
#define TWR_JOBID_RT_FINFAIL 81303ULL
#define TWR_JOBID_RT_UNREC 81304ULL
#define TWR_RT_N_RES 9
/* Mirrors test_worker_join.c's own new_test_endpoint/pop_and_decode/assert_
* queue_empty helpers exactly -- real (heap) endpoint_t via endpoint_get()
* (bug-378: never a hand-rolled/zeroed stack stand-in for anything actually
* sent/received over), for genuine wire capture of both the query this test
* sends TO the worker and the report it simulates receiving FROM one. */
static endpoint_t* twr_rt_new_test_endpoint(void)
{
endpoint_t* ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
return ep;
}
static void twr_rt_pop_and_decode(endpoint_t* ep, xce_request_t* out)
{
data_item_t* it = endpoint_deq(ep);
TEST_ASSERT_NOT_NULL(it);
io_packet_t* pkt = ITPTR_TO_PACKET(it);
memset(out, 0, sizeof(*out));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, out)); /* consumes/frees pkt */
}
static void test_full_rejoin_reconciliation_round_trip(void)
{
char tmp[256];
uint64_t worker_reported_finish_time_s;
int32_t worker_reported_exit_code = 42;
strcpy(tmp, "/tmp/xce_twr71_roundtrip_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Deliberately far from "now" so a reconciliation-processing-time bug
* (time(NULL)) could never accidentally produce a matching value --
* same defensive idiom test_apply_report_finished_success_uses_worker_
* reported_finish_time already establishes. */
worker_reported_finish_time_s = (uint64_t)time(NULL) - 3600;
/* Phase 1: real cluster (workermgr + resmgr + jobmgr + hooksys) + real
* disk persist -- drives the REAL join/query-send/report-apply chain.
* Own local cluster_t (not the shared g_twr fixture), mirroring every
* other restart-replay test in this file, since Phase 1's disk state
* must survive past Phase 1's own teardown into Phase 2 below. */
{
cluster_t c;
kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING,
.nslots = 64, .delta = 16 };
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
worker_t* w;
job_t* jobs[4];
uint64_t jobids[4] = { TWR_JOBID_RT_RUNNING,
TWR_JOBID_RT_FINOK,
TWR_JOBID_RT_FINFAIL,
TWR_JOBID_RT_UNREC };
hostattrs_t attrs, new_attrs;
xce_hostconfig_t hconfig;
endpoint_t stack_ep;
endpoint_t* ep;
xce_request_t decoded_query, decoded_report;
int saw[4] = { 0, 0, 0, 0 };
int i, rc;
uint64_t lsn_before, lsn_after;
static const char* res_names[TWR_RT_N_RES] = {
"type", "model", "cpuf", "maxcpus", "maxmem",
"maxswap", "maxtmp", "ndisks", "slots"
};
static const int res_valtypes[TWR_RT_N_RES] = {
XCE_VAL_STRING, XCE_VAL_STRING,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC
};
if (makeString((char*)"twr71-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.workermgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.workermgr);
c.resmgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.resmgr);
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
for (i = 0; i < TWR_RT_N_RES; i++) {
xce_resource_t rdef;
memset(&rdef, 0, sizeof(rdef));
rdef.name = (char*)res_names[i];
rdef.valtype = res_valtypes[i];
rdef.flags = 0;
rdef.idx = -1;
TEST_ASSERT_NOT_NULL(makeResource(c.resmgr, &rdef));
}
c.myhost_info.name = (char*)"twr71-controller";
c.myhost_info.key = (char*)"twr71-key";
c.myhost = &c.myhost_info;
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
/* Seed a durable WAL_JOB_SUBMIT fact + a matching in-memory job_t
* for all 4 jobs -- all RUN, all assigned to the SAME worker host,
* exactly what the controller believes right before this rejoin
* (bypassing dispatch/replay, mirroring every sibling restart-
* replay test in this file). All 4 are non-terminal, so all 4 are
* expected in the round's query set (Req 2.1) -- "unrecognized" is
* not a DIFFERENT starting state from the controller's own point of
* view, only a different worker ANSWER. */
for (i = 0; i < 4; i++) {
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
job_submit_data_t submit;
xce_job_t jdef;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = jobids[i];
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = jobids[i];
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
jobs[i] = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(jobs[i]);
jobs[i]->job.allocs = twr_make_test_alloc_for_host(
jobids[i], "resync-roundtrip-host");
TEST_ASSERT_NOT_NULL(jobs[i]->job.allocs);
}
/* ── Join: rejoin_worker (the REAL join entry point) arms the round,
* worker_send_resync_query sends the REAL wire query (Req 1.1-1.3,
* 2.1, 2.2). */
memset(&attrs, 0, sizeof(attrs));
attrs.hosttype = makeString("LINUX");
attrs.hostmodel = makeString("x86");
attrs.cpufactor = 1.0f;
attrs.maxcpus = 4;
attrs.maxmem = 16;
attrs.maxswap = 32;
attrs.maxtmp = 64;
attrs.ndisks = 1;
{
xce_worker_t host;
memset(&host, 0, sizeof(host));
host.name = (char*)"resync-roundtrip-host";
host.attrs = &attrs;
w = makeWorker(c.workermgr, &host);
}
TEST_ASSERT_NOT_NULL(w);
memset(&new_attrs, 0, sizeof(new_attrs));
new_attrs.hosttype = makeString("LINUX");
new_attrs.hostmodel = makeString("x86");
new_attrs.cpufactor = 1.0f;
new_attrs.maxcpus = 4;
new_attrs.maxmem = 16;
new_attrs.maxswap = 32;
new_attrs.maxtmp = 64;
new_attrs.ndisks = 1;
memset(&hconfig, 0, sizeof(hconfig));
hconfig.hosttype = (char*)"!";
hconfig.hostmodel = (char*)"!";
hconfig.mxj = 4;
memset(&stack_ep, 0, sizeof(stack_ep));
rc = rejoin_worker(&c, w, &stack_ep, &new_attrs, &hconfig);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"a rejoin with 4 known non-terminal jobs on this host must arm "
"a reconciliation round (Req 1.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(4, w->resync->n_queried_jobids,
"the round's query set must match exactly the host's 4 "
"non-terminal jobs (Req 2.1)");
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w->host.status));
ep = twr_rt_new_test_endpoint();
TEST_ASSERT_EQUAL_INT(0, worker_send_resync_query(&c, w, ep));
twr_rt_pop_and_decode(ep, &decoded_query);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateQuery, decoded_query.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatequerymsg,
decoded_query.body_which);
TEST_ASSERT_NOT_NULL(decoded_query.body.jobstatequerymsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(4,
decoded_query.body.jobstatequerymsg->n_total,
"the REAL wire query (Req 2.1, 2.2) must list exactly the 4 "
"armed jobids");
for (i = 0; i < decoded_query.body.jobstatequerymsg->count; i++) {
uint64_t id = decoded_query.body.jobstatequerymsg->jobids[i];
if (id == TWR_JOBID_RT_RUNNING) saw[0] = 1;
if (id == TWR_JOBID_RT_FINOK) saw[1] = 1;
if (id == TWR_JOBID_RT_FINFAIL) saw[2] = 1;
if (id == TWR_JOBID_RT_UNREC) saw[3] = 1;
}
TEST_ASSERT_TRUE_MESSAGE(saw[0] && saw[1] && saw[2] && saw[3],
"the wire query must cover all 4 jobs by unique ID (Req 2.2)");
xce_free_request(&decoded_query);
/* ── Worker's answer: a REAL wire-encoded-then-decoded OPCode_
* jobStateReport (task 1.4's own xce_job_state_report_req()/xce_
* decode() codec), covering all 4 outcomes in one page, fed into
* resync_handle_report_page() -- task 6.2's own dispatch/
* reassembly entry point, never resync_apply_report() directly
* (Req 3.1-3.3, 3.6, 8.1-8.3). */
{
xce_job_state_entry_t e_running, e_finok, e_finfail, e_unrec;
xce_job_state_entry_t* entries[4];
xce_job_state_report_t report;
e_running = (xce_job_state_entry_t){
.jobid = TWR_JOBID_RT_RUNNING,
.status = JobStateStatus_running,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
e_finok = (xce_job_state_entry_t){
.jobid = TWR_JOBID_RT_FINOK,
.status = JobStateStatus_finishedOk,
.exit_code = 0,
.finish_time_ms = worker_reported_finish_time_s * 1000ULL,
.queried = 1
};
e_finfail = (xce_job_state_entry_t){
.jobid = TWR_JOBID_RT_FINFAIL,
.status = JobStateStatus_finishedFail,
.exit_code = worker_reported_exit_code,
.finish_time_ms = 0, .queried = 1
};
e_unrec = (xce_job_state_entry_t){
.jobid = TWR_JOBID_RT_UNREC,
.status = JobStateStatus_unrecognized,
.exit_code = 0, .finish_time_ms = 0, .queried = 1
};
entries[0] = &e_running;
entries[1] = &e_finok;
entries[2] = &e_finfail;
entries[3] = &e_unrec;
report = (xce_job_state_report_t){
.n_total = 4, .pos = 0, .count = 4, .entries = entries
};
TEST_ASSERT_EQUAL_INT(0,
xce_job_state_report_req(ep, c.myhost, &report, NULL, NULL,
0));
}
twr_rt_pop_and_decode(ep, &decoded_report);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded_report.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatereportmsg,
decoded_report.body_which);
TEST_ASSERT_NOT_NULL(decoded_report.body.jobstatereportmsg);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
pthread_mutex_lock(&c.persist.lock);
lsn_before = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
rc = resync_handle_report_page(&c, w,
decoded_report.body.jobstatereportmsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"a single-page report (count == n_total) must complete the "
"report immediately");
pthread_mutex_lock(&c.persist.lock);
lsn_after = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
xce_free_request(&decoded_report);
/* ── End-to-end assertions (Phase 1 -- everything observable before
* any commit round trip, mirroring record_job_reconcile_pending_
* fact's own eager-mutation precedent). */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, jobs[0]->job.state,
"still-running (queried) must be left exactly as-is -- no fact "
"recorded (Req 4.1)");
TEST_ASSERT_NOT_NULL(jobs[0]->job.allocs);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, jobs[1]->job.state,
"finished-success stays in the DEFERRED record_job_finish_"
"fact_at pattern -- state mutates later via apply_committed_"
"entry (proven below, Phase 2), not synchronously here, exactly "
"as test_apply_report_all_five_outcomes_updates_job_table's own "
"established precedent documents");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, jobs[2]->job.state,
"finished-failure stays in the DEFERRED on_job_fail/record_job_"
"fail_fact pattern -- same deferred precedent as finished-"
"success");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, jobs[3]->job.state,
"unrecognized reconciles EAGERLY through record_job_reconcile_"
"pending_fact (Req 4.5, 4.6) -- PEND synchronously, before any "
"commit round trip");
TEST_ASSERT_NULL_MESSAGE(jobs[3]->job.allocs,
"bug-1631: reconciled-to-pending must release the allocation "
"synchronously");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"once every one of the 4 queried jobs has a reconciled outcome, "
"the round must resolve (Req 5.1, 5.2)");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_update_hook_fires,
"Req 7.1, 7.2, 7.3: the EAGER unrecognized-to-pending "
"transition must reach the scheduler's/apiserver's notify path "
"SYNCHRONOUSLY, within this same call -- the two DEFERRED "
"outcomes (finished-success/failure) have not applied yet at "
"this point, so they must NOT have notified yet either "
"(confirmed to notify later, in Phase 2 below)");
TEST_ASSERT_TRUE_MESSAGE(lsn_after >= lsn_before + 3,
"at least 3 durable facts must be appended for this report: "
"finished-success (WAL_JOB_FINISH), finished-failure (WAL_JOB_"
"FAIL), and unrecognized (WAL_JOB_UPDATE) -- the still-running "
"entry records none");
xce_disable_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
if (c.workermgr) dereference(c.workermgr);
if (c.resmgr) dereference(c.resmgr);
if (c.jobmgr) dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
dereference(new_attrs.hosttype);
dereference(new_attrs.hostmodel);
endpoint_fini(ep);
free(ep);
}
/* Phase 2: fresh persist_t + fresh cluster_t (jobmgr/hooksys only),
* recover from disk -- simulates a controller restart, the SAME apply_
* committed_entry() seam a live controller's own commit-apply pump
* would invoke to settle the two DEFERRED outcomes above. Mirrors
* test_apply_report_finished_success_uses_worker_reported_finish_
* time's own established two-phase idiom exactly. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2_running = NULL;
job_t* j2_finok = NULL;
job_t* j2_finfail = NULL;
job_t* j2_unrec = NULL;
int rc;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_RT_RUNNING, (void**)&j2_running);
key_find(c2.jobmgr, (void*)TWR_JOBID_RT_FINOK, (void**)&j2_finok);
key_find(c2.jobmgr, (void*)TWR_JOBID_RT_FINFAIL, (void**)&j2_finfail);
key_find(c2.jobmgr, (void*)TWR_JOBID_RT_UNREC, (void**)&j2_unrec);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL(j2_running);
TEST_ASSERT_NOT_NULL(j2_finok);
TEST_ASSERT_NOT_NULL(j2_finfail);
TEST_ASSERT_NOT_NULL(j2_unrec);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, j2_running->job.state,
"still-running: no fact was ever recorded for it, so replay "
"leaves it exactly as WAL_JOB_SUBMIT alone reconstructs it "
"(Req 4.1)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j2_finok->job.state,
"finished-success: the DEFERRED record_job_finish_fact_at fact "
"must apply to DONE on replay (Req 4.1)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(worker_reported_finish_time_s,
(uint64_t)j2_finok->job.finish_time,
"Req 4.2, 3.5: the recovered finish_time must be the WORKER-"
"reported finish_time_ms, not reconciliation-processing "
"wall-clock time");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_EXIT, j2_finfail->job.state,
"finished-failure: the DEFERRED on_job_fail/record_job_fail_"
"fact fact must apply to EXIT on replay (Req 4.1)");
/* Req 4.2 remains DOCUMENTED-unsatisfiable for the finished-
* FAILURE finish_time specifically (bug-1747) -- not asserted here.
* The worker-reported EXIT CODE, however, is durably recorded via
* WAL_JOB_FAIL's code trailer since bug-1754. */
TEST_ASSERT_NOT_NULL_MESSAGE(j2_finfail->job.exit_info,
"bug-1754: a finished-failure's worker-reported exit code must "
"survive restart replay in job.exit_info");
TEST_ASSERT_EQUAL_INT(worker_reported_exit_code,
j2_finfail->job.exit_info->exitcode);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND, j2_unrec->job.state,
"unrecognized: the EAGER record_job_reconcile_pending_fact's "
"durable WAL_JOB_UPDATE fact must ALSO reconstruct correctly on "
"replay -- convergence via either the live eager path (Phase 1 "
"above) or a restart, not just the live path (Req 4.5, 4.6)");
TEST_ASSERT_NULL(j2_unrec->job.allocs);
TEST_ASSERT_EQUAL_INT_MESSAGE(3, g_twr_update_hook_fires,
"Req 7.1-7.3: replaying the durable facts this reconciliation "
"round produced must reach the scheduler's/apiserver's notify "
"path (HOOK_DETAIL_JOB_UPDATE) for EVERY actual state change -- "
"finished-success, finished-failure, and unrecognized-to-"
"pending -- exactly 3 times (still-running recorded no fact, "
"and WAL_JOB_SUBMIT fires HOOK_DETAIL_JOB_NEW, not JOB_UPDATE, "
"so it does not add to this count). Together with Phase 1's own "
"single synchronous fire, this confirms EVERY actual state "
"change this round produced -- both the eager and the deferred "
"ones -- reaches the same notify path, not just some of them");
xce_disable_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
printf("test_full_rejoin_reconciliation_round_trip: PASS\n");
}
/* ── Task 7.2: Failover-during-job recovery -- the feature's own core
* motivating scenario (bug-1741; requirements.md's Introduction; design.md
* "Testing Strategy" > "Integration Tests": "Failover-during-job: a job's
* finish notify is lost across a primary failover; the new primary's
* rejoin reconciliation recovers it (this is the feature's core bug-1741
* regression scenario)") ────────────────────────────────────────────────
*
* Proves the SIMPLEST version of bug-1741's own reported failure: one job,
* one lost finish report, one failover, one rejoin. bug-1741 itself was a
* burst of 7 near-simultaneous finish reports losing 6 of them; that
* burst/many-jobs shape is Requirement 8's own separate coverage (already
* proven by the multi-page tests above and by test_full_rejoin_
* reconciliation_round_trip's four-outcome round trip) -- this test's own
* job is only to name and prove the single-job case in isolation, not to
* re-prove the general round-trip machinery task 7.1 already covers.
*
* Per requirements.md's Introduction, today a worker's finish report is a
* single fire-and-forget XCECLI_FF_CALL send with no retry -- if it is
* lost (the worker briefly disconnects, or the controller it was talking
* to fails over to a new primary mid-flight), the job's true outcome is
* never recorded anywhere and its state (RUN) sticks forever. This test
* models that starting condition directly: a fresh persist state in which
* the job is durably recorded as RUN, assigned to a worker host, with NO
* finish fact of any kind ever appended for it -- exactly as if the real
* XCECLI_FF_CALL send had simply vanished before this feature ever
* existed. No second controller process or literal primary-to-primary
* handoff mechanism is simulated -- the OBSERVABLE starting condition (RUN
* job, no finish fact, worker has since actually finished it) is what
* this feature's own recovery mechanism must correct, regardless of how
* that condition came to exist; per Requirement 1.3, the controller's own
* trigger does not distinguish a first join, an ordinary reconnect, or a
* post-failover reconnect to a new primary -- the same rejoin path
* (`rejoin_worker`) is exercised below exactly as it would be for any of
* the three.
*
* Drives the SAME real rejoin -> query-send -> report-apply chain task
* 7.1's own round-trip test established (`rejoin_worker`, the REAL join
* entry point; `worker_send_resync_query`'s REAL wire OPCode_jobStateQuery;
* a REAL worker answer via `xce_job_state_report_req`'s REAL wire OPCode_
* jobStateReport; `resync_handle_report_page`, never `resync_apply_report`
* directly) -- narrowed to ONE job, ONE outcome (finished-success),
* because this test's whole point is to isolate the bug-1741 scenario, not
* to duplicate the four-outcome coverage task 7.1 already built.
*
* finished-success stays on record_job_finish_fact_at's DEFERRED pattern
* (task 3.2/bug-1747) -- so, like test_full_rejoin_reconciliation_round_
* trip's own Phase 1/Phase 2 split, this test needs a real restart-replay
* (Phase 2, the SAME apply_committed_entry() seam a live controller's own
* commit-apply pump -- or another controller instance recovering the same
* durable log -- would invoke) to observe the job actually reach DONE;
* this bare-persist test fixture has no live commit-engine apply pump of
* its own, exactly the situation task 7.1's own Implementation Notes
* document. */
#define TWR_JOBID_FAILOVER_LOST 81401ULL
static void test_failover_during_job_recovers_lost_finish(void)
{
char tmp[256];
uint64_t worker_reported_finish_time_s;
strcpy(tmp, "/tmp/xce_twr72_failover_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Deliberately far in the past -- the job genuinely finished well
* before this reconciliation round runs, so a reconciliation-
* processing-time bug (time(NULL)) could never accidentally produce a
* matching value. Same defensive idiom as this file's other finish-
* time tests. */
worker_reported_finish_time_s = (uint64_t)time(NULL) - 5400;
/* Phase 1: the (new) primary's own persist state -- job RUN, no
* finish fact anywhere in it (the lost-notify starting condition) --
* then the REAL rejoin/query/report chain. */
{
cluster_t c;
kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING,
.nslots = 64, .delta = 16 };
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
worker_t* w;
job_t* job;
hostattrs_t attrs, new_attrs;
xce_hostconfig_t hconfig;
endpoint_t stack_ep;
endpoint_t* ep;
xce_request_t decoded_query, decoded_report;
int rc;
uint64_t lsn_before, lsn_after;
int i;
static const char* res_names[TWR_RT_N_RES] = {
"type", "model", "cpuf", "maxcpus", "maxmem",
"maxswap", "maxtmp", "ndisks", "slots"
};
static const int res_valtypes[TWR_RT_N_RES] = {
XCE_VAL_STRING, XCE_VAL_STRING,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC
};
if (makeString((char*)"twr72-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.workermgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.workermgr);
c.resmgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.resmgr);
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
for (i = 0; i < TWR_RT_N_RES; i++) {
xce_resource_t rdef;
memset(&rdef, 0, sizeof(rdef));
rdef.name = (char*)res_names[i];
rdef.valtype = res_valtypes[i];
rdef.flags = 0;
rdef.idx = -1;
TEST_ASSERT_NOT_NULL(makeResource(c.resmgr, &rdef));
}
c.myhost_info.name = (char*)"twr72-controller";
c.myhost_info.key = (char*)"twr72-key";
c.myhost = &c.myhost_info;
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
/* Seed a durable WAL_JOB_SUBMIT fact + a matching in-memory job_t,
* RUN, assigned to the worker host -- and NOTHING ELSE. No
* WAL_JOB_START/DISPATCH/FINISH is ever appended for this job in
* this log: the controller's own durable state simply has no
* record the job ever finished, exactly bug-1741's own reported
* gap. */
{
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
job_submit_data_t submit;
xce_job_t jdef;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_FAILOVER_LOST;
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_FAILOVER_LOST;
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
job = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(job);
job->job.allocs = twr_make_test_alloc_for_host(
TWR_JOBID_FAILOVER_LOST, "resync-failover-host");
TEST_ASSERT_NOT_NULL(job->job.allocs);
}
/* ── Worker rejoin: the REAL join entry point. Requirement 1.3 --
* the controller does not need to know or care whether this is
* the host's first-ever join, an ordinary network reconnect, or
* (this scenario) a reconnect to a new primary after a controller
* failover; the same trigger fires because the controller still
* holds one non-terminal job for this host. */
memset(&attrs, 0, sizeof(attrs));
attrs.hosttype = makeString("LINUX");
attrs.hostmodel = makeString("x86");
attrs.cpufactor = 1.0f;
attrs.maxcpus = 4;
attrs.maxmem = 16;
attrs.maxswap = 32;
attrs.maxtmp = 64;
attrs.ndisks = 1;
{
xce_worker_t host;
memset(&host, 0, sizeof(host));
host.name = (char*)"resync-failover-host";
host.attrs = &attrs;
w = makeWorker(c.workermgr, &host);
}
TEST_ASSERT_NOT_NULL(w);
memset(&new_attrs, 0, sizeof(new_attrs));
new_attrs.hosttype = makeString("LINUX");
new_attrs.hostmodel = makeString("x86");
new_attrs.cpufactor = 1.0f;
new_attrs.maxcpus = 4;
new_attrs.maxmem = 16;
new_attrs.maxswap = 32;
new_attrs.maxtmp = 64;
new_attrs.ndisks = 1;
memset(&hconfig, 0, sizeof(hconfig));
hconfig.hosttype = (char*)"!";
hconfig.hostmodel = (char*)"!";
hconfig.mxj = 4;
memset(&stack_ep, 0, sizeof(stack_ep));
rc = rejoin_worker(&c, w, &stack_ep, &new_attrs, &hconfig);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"a rejoin finding this host's one lost-finish job still "
"recorded as RUN must arm a reconciliation round (Req 1.1, "
"1.3)");
TEST_ASSERT_EQUAL_INT(1, w->resync->n_queried_jobids);
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w->host.status));
ep = twr_rt_new_test_endpoint();
TEST_ASSERT_EQUAL_INT(0, worker_send_resync_query(&c, w, ep));
twr_rt_pop_and_decode(ep, &decoded_query);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateQuery, decoded_query.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatequerymsg,
decoded_query.body_which);
TEST_ASSERT_NOT_NULL(decoded_query.body.jobstatequerymsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
decoded_query.body.jobstatequerymsg->n_total,
"the REAL wire query must ask about exactly the one job the "
"controller still believes is running (Req 2.1)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(TWR_JOBID_FAILOVER_LOST,
decoded_query.body.jobstatequerymsg->jobids[0],
"the query must identify the job unambiguously by its ID "
"(Req 2.2)");
xce_free_request(&decoded_query);
/* ── The worker's REAL answer: it actually finished this job
* successfully, long before this rejoin -- the controller simply
* never heard about it (the lost XCECLI_FF_CALL). */
{
xce_job_state_entry_t e;
xce_job_state_entry_t* entries[1];
xce_job_state_report_t report;
e = (xce_job_state_entry_t){
.jobid = TWR_JOBID_FAILOVER_LOST,
.status = JobStateStatus_finishedOk,
.exit_code = 0,
.finish_time_ms = worker_reported_finish_time_s * 1000ULL,
.queried = 1
};
entries[0] = &e;
report = (xce_job_state_report_t){
.n_total = 1, .pos = 0, .count = 1, .entries = entries
};
TEST_ASSERT_EQUAL_INT(0,
xce_job_state_report_req(ep, c.myhost, &report, NULL, NULL,
0));
}
twr_rt_pop_and_decode(ep, &decoded_report);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded_report.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatereportmsg,
decoded_report.body_which);
TEST_ASSERT_NOT_NULL(decoded_report.body.jobstatereportmsg);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
pthread_mutex_lock(&c.persist.lock);
lsn_before = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
rc = resync_handle_report_page(&c, w,
decoded_report.body.jobstatereportmsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"a single-page report (count == n_total) must complete the "
"report immediately");
pthread_mutex_lock(&c.persist.lock);
lsn_after = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
xce_free_request(&decoded_report);
/* ── Phase 1 assertions: the round resolves and a durable fact
* capturing the worker's TRUE outcome is appended -- but the job
* is NOT yet observably DONE in this process's own memory. That
* is expected and correct: finished-success reconciles through
* record_job_finish_fact_at's DEFERRED pattern (task 3.2/bug-
* 1747), same as an ordinary finish report -- the state mutation
* happens later, when the fact is applied (Phase 2 below), never
* synchronously here. */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN, job->job.state,
"finished-success stays RUN synchronously -- the DEFERRED "
"record_job_finish_fact_at pattern applies the state change "
"later, via apply_committed_entry (Phase 2)");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"the round's only queried job has a reconciled outcome -- it "
"must resolve (Req 5.1, 5.2)");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_twr_update_hook_fires,
"finished-success is DEFERRED -- no HOOK_DETAIL_JOB_UPDATE "
"fire is expected synchronously here; that fire is proven "
"below, in Phase 2, once the durable fact actually applies");
TEST_ASSERT_TRUE_MESSAGE(lsn_after >= lsn_before + 1,
"the worker's true finished-success outcome must be durably "
"appended (WAL_JOB_FINISH) as part of this report, even "
"though it has not applied to in-memory state yet");
xce_disable_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
if (c.workermgr) dereference(c.workermgr);
if (c.resmgr) dereference(c.resmgr);
if (c.jobmgr) dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
dereference(new_attrs.hosttype);
dereference(new_attrs.hostmodel);
endpoint_fini(ep);
free(ep);
}
/* Phase 2: fresh persist_t + fresh cluster_t, persist_recover() -- the
* SAME apply_committed_entry() seam a live controller's own commit-
* apply pump (or another controller instance recovering this same
* durable log) would invoke. THIS is the test's core, observable
* completion criterion: the job the controller's own durable state
* showed stuck in RUN a moment ago, with its finish notify never
* having reached any controller instance, now reconstructs as
* genuinely finished -- DONE, with the worker's own true finish time,
* not reconciliation-processing wall-clock time -- because the
* worker rejoined and this feature's reconciliation round recovered
* it (Req 4.1, 4.2). */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2 = NULL;
int rc;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_FAILOVER_LOST, (void**)&j2);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL_MESSAGE(j2,
"restart/rejoin-to-new-primary replay must reconstruct the "
"job from its WAL_JOB_SUBMIT fact");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j2->job.state,
"the feature's core completion criterion (bug-1741): a job "
"whose finish notify never reached any controller instance "
"ends up correctly finished, not stuck in RUN, once the "
"worker rejoins the new primary (Req 4.1)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(worker_reported_finish_time_s,
(uint64_t)j2->job.finish_time,
"the recovered finish_time must be the WORKER-reported "
"finish_time_ms from the reconciliation report, not "
"reconciliation-processing or replay wall-clock time "
"(Req 4.2, 3.5)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_twr_update_hook_fires,
"replaying the durable fact this reconciliation round "
"produced must reach the scheduler's/apiserver's notify path "
"(HOOK_DETAIL_JOB_UPDATE) exactly once for this one real "
"state change (Req 7.1-7.3)");
xce_disable_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
printf("test_failover_during_job_recovers_lost_finish: PASS\n");
}
/* ── Task 7.3: Burst/array recovery unbounded by job count -- bug-1741's OWN
* exact originally-reported shape (requirements.md's Introduction: "a burst
* of near-simultaneous job completions on one worker host lost 6 of 7
* finish reports, leaving those jobs stuck in RUN forever while their
* siblings all completed normally") ────────────────────────────────────────
*
* Task 7.1 proved the general four-outcome round trip (one job per
* outcome); task 7.2 proved the single-job lost-finish recovery in
* isolation. Neither proves what THIS task is for: bug-1741's own reported
* shape was SEVERAL jobs at once, MOST lost, with recovery required to
* scale correctly across all of them together in ONE round -- not merely
* repeat the single-job case N times with no cross-job interaction check
* (tasks.md's own Observable Completion Criterion for this task).
*
* 7 jobs total on ONE worker host (bug-1741's own literal numbers):
* - 1 job (TWR_JOBID_BURST_ALREADY_DONE) already finished successfully
* BEFORE this rejoin -- a durable WAL_JOB_SUBMIT + WAL_JOB_FINISH pair
* is already on disk (the FINISH appended directly via persist_submit_
* job_finish_fact(), the same primitive record_job_finish_fact_at()
* itself calls -- modeling a finish report that DID reach and was
* durably committed by an earlier controller instance, before this
* rejoin), in-memory state already DONE. Being terminal, it must NOT be
* part of the round's query set (Req 1.1, 1.2, 2.1) -- but the worker's
* own retention window (Req 3.6, 3.7) would still recognize it as
* "recently finished" and report it AGAIN, as an unqueried extra, this
* time carrying a deliberately WRONG (bogus, recent) finish_time_ms.
* This is this task's own "none duplicated" Observable Completion
* Criterion, made concrete: this job's already-recorded outcome must
* survive completely unchanged -- both synchronously (Phase 1) and
* after a real restart replay (Phase 2) -- never overwritten by the
* bogus re-report, and with NO new WAL fact appended for it at all.
* - 6 jobs (TWR_JOBID_BURST_LOST_1..6) mirror bug-1741's own "6 of 7 lost"
* ratio exactly: each is durably recorded RUN with NO finish fact of
* any kind ever appended (the lost fire-and-forget XCECLI_FF_CALL, the
* same starting condition as task 7.2's single-job case) -- all 6 are
* non-terminal and assigned to the SAME host, so all 6 are armed into
* ONE round's query set together (Req 2.1, 8.1). Each carries its OWN
* distinct worker-reported finish_time_ms/exit_code (5 finished-success
* + 1 finished-failure, a genuine mixed-outcome burst) -- distinct per
* job specifically so a bug that mixes up entries across the loop (an
* off-by-one / shared-variable defect in resync_apply_report's
* per-entry routing) would surface as a WRONG value under a SPECIFIC
* jobid, never masked by every job sharing one identical expected
* outcome.
*
* Drives the SAME real rejoin -> query-send -> report-apply chain tasks
* 7.1/7.2 already established (rejoin_worker, worker_send_resync_query's
* real wire OPCode_jobStateQuery, a real worker answer via xce_job_state_
* report_req's real wire OPCode_jobStateReport, resync_handle_report_page,
* never resync_apply_report directly) -- one single-page report covering
* all 7 entries together (6 queried + 1 unqueried extra). Deliberately NOT
* forced across multiple wire pages: task 7.4's own separately-scoped job
* is proving PAGINATION boundary correctness at a much larger count; this
* task's job is proving RECOVERY LOGIC correctness at bug-1741's own
* "several jobs at once" scale, per tasks.md's explicit boundary note
* separating the two. */
#define TWR_JOBID_BURST_ALREADY_DONE 81501ULL
#define TWR_JOBID_BURST_LOST_1 81502ULL
#define TWR_JOBID_BURST_LOST_2 81503ULL
#define TWR_JOBID_BURST_LOST_3 81504ULL
#define TWR_JOBID_BURST_LOST_4 81505ULL
#define TWR_JOBID_BURST_LOST_5 81506ULL
#define TWR_JOBID_BURST_LOST_6 81507ULL /* finished-FAILURE, not OK */
#define TWR_BURST_N_LOST 6
#define TWR_BURST_N_TOTAL 7 /* 1 already-done + 6 lost -- bug-1741's own 7/6 numbers */
static void test_burst_recovery_unbounded_by_job_count(void)
{
char tmp[256];
uint64_t already_done_finish_time_s;
uint64_t bogus_reapply_finish_time_s;
uint64_t lost_finish_time_s[TWR_BURST_N_LOST - 1]; /* LOST_1..LOST_5 (finished-ok) */
int32_t lost_fail_exit_code = 66;
strcpy(tmp, "/tmp/xce_twr73_burst_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Deliberately distinct/far-from-"now" values throughout -- so a
* reconciliation-processing-time bug (time(NULL)) or a cross-job value
* mix-up could never accidentally produce a matching value, same
* defensive idiom this file's other finish-time tests already
* establish. */
already_done_finish_time_s = (uint64_t)time(NULL) - 7200;
bogus_reapply_finish_time_s = (uint64_t)time(NULL) - 30;
{
int k;
for (k = 0; k < TWR_BURST_N_LOST - 1; k++)
lost_finish_time_s[k]
= (uint64_t)time(NULL) - 3600 - (uint64_t)(k * 137);
}
/* Phase 1: real cluster (workermgr + resmgr + jobmgr + hooksys) + real
* disk persist -- drives the REAL join/query-send/report-apply chain
* over all 7 jobs together. Own local cluster_t (not the shared g_twr
* fixture), mirroring every other restart-replay test in this file. */
{
cluster_t c;
kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING,
.nslots = 64, .delta = 16 };
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
persist_config_t cfg;
worker_t* w;
job_t* j_done;
job_t* j_lost[TWR_BURST_N_LOST];
uint64_t lost_ids[TWR_BURST_N_LOST] = {
TWR_JOBID_BURST_LOST_1, TWR_JOBID_BURST_LOST_2,
TWR_JOBID_BURST_LOST_3, TWR_JOBID_BURST_LOST_4,
TWR_JOBID_BURST_LOST_5, TWR_JOBID_BURST_LOST_6
};
hostattrs_t attrs, new_attrs;
xce_hostconfig_t hconfig;
endpoint_t stack_ep;
endpoint_t* ep;
xce_request_t decoded_query, decoded_report;
int saw[TWR_BURST_N_LOST];
int i, rc;
uint64_t lsn_before, lsn_after;
static const char* res_names[TWR_RT_N_RES] = {
"type", "model", "cpuf", "maxcpus", "maxmem",
"maxswap", "maxtmp", "ndisks", "slots"
};
static const int res_valtypes[TWR_RT_N_RES] = {
XCE_VAL_STRING, XCE_VAL_STRING,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC
};
if (makeString((char*)"twr73-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.workermgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.workermgr);
c.resmgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.resmgr);
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
for (i = 0; i < TWR_RT_N_RES; i++) {
xce_resource_t rdef;
memset(&rdef, 0, sizeof(rdef));
rdef.name = (char*)res_names[i];
rdef.valtype = res_valtypes[i];
rdef.flags = 0;
rdef.idx = -1;
TEST_ASSERT_NOT_NULL(makeResource(c.resmgr, &rdef));
}
c.myhost_info.name = (char*)"twr73-controller";
c.myhost_info.key = (char*)"twr73-key";
c.myhost = &c.myhost_info;
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
/* Seed the ALREADY-DONE job: durable WAL_JOB_SUBMIT + a REAL,
* already-committed WAL_JOB_FINISH (appended directly via
* persist_submit_job_finish_fact -- modeling a finish report that
* reached and was durably recorded by an EARLIER controller
* instance, before this rejoin) -- plus a matching in-memory job_t
* already DONE, allocs already released, exactly what a genuinely
* finished job looks like. */
{
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
job_submit_data_t submit;
xce_job_t jdef;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = TWR_JOBID_BURST_ALREADY_DONE;
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
TEST_ASSERT_EQUAL_INT(0,
persist_submit_job_finish_fact(&c.persist,
TWR_JOBID_BURST_ALREADY_DONE, JOB_STAT_DONE,
already_done_finish_time_s, 0));
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = TWR_JOBID_BURST_ALREADY_DONE;
jdef.state = JOB_STAT_DONE;
jdef.submit = &submit;
j_done = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(j_done);
j_done->job.finish_time = (time_t)already_done_finish_time_s;
j_done->job.exit_info
= (job_exitinfo_t*)calloc(1, sizeof(job_exitinfo_t));
TEST_ASSERT_NOT_NULL(j_done->job.exit_info);
j_done->job.exit_info->exitcode = 0;
TEST_ASSERT_NULL(j_done->job.allocs);
}
/* Seed the 6 LOST jobs: durable WAL_JOB_SUBMIT only, RUN, all
* assigned to the SAME host -- NO finish fact of any kind, the
* lost-fire-and-forget-send starting condition, x6 (bug-1741's own
* "6 of 7 lost" ratio). */
for (i = 0; i < TWR_BURST_N_LOST; i++) {
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
job_submit_data_t submit;
xce_job_t jdef;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = lost_ids[i];
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = lost_ids[i];
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
j_lost[i] = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(j_lost[i]);
j_lost[i]->job.allocs = twr_make_test_alloc_for_host(
lost_ids[i], "resync-burst-host");
TEST_ASSERT_NOT_NULL(j_lost[i]->job.allocs);
}
/* ── Join: rejoin_worker (the REAL join entry point) arms the round
* over exactly the 6 non-terminal (lost) jobs -- the 7th,
* already-DONE job must NOT be part of the query set (Req 1.1,
* 1.2, 2.1). */
memset(&attrs, 0, sizeof(attrs));
attrs.hosttype = makeString("LINUX");
attrs.hostmodel = makeString("x86");
attrs.cpufactor = 1.0f;
attrs.maxcpus = 4;
attrs.maxmem = 16;
attrs.maxswap = 32;
attrs.maxtmp = 64;
attrs.ndisks = 1;
{
xce_worker_t host;
memset(&host, 0, sizeof(host));
host.name = (char*)"resync-burst-host";
host.attrs = &attrs;
w = makeWorker(c.workermgr, &host);
}
TEST_ASSERT_NOT_NULL(w);
memset(&new_attrs, 0, sizeof(new_attrs));
new_attrs.hosttype = makeString("LINUX");
new_attrs.hostmodel = makeString("x86");
new_attrs.cpufactor = 1.0f;
new_attrs.maxcpus = 4;
new_attrs.maxmem = 16;
new_attrs.maxswap = 32;
new_attrs.maxtmp = 64;
new_attrs.ndisks = 1;
memset(&hconfig, 0, sizeof(hconfig));
hconfig.hosttype = (char*)"!";
hconfig.hostmodel = (char*)"!";
hconfig.mxj = 4;
memset(&stack_ep, 0, sizeof(stack_ep));
rc = rejoin_worker(&c, w, &stack_ep, &new_attrs, &hconfig);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"a rejoin with 6 known non-terminal jobs on this host must arm "
"a reconciliation round (Req 1.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_BURST_N_LOST,
w->resync->n_queried_jobids,
"the round's query set must match exactly the host's 6 "
"non-terminal jobs -- the already-DONE 7th job must be "
"excluded (Req 1.1, 1.2, 2.1)");
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w->host.status));
ep = twr_rt_new_test_endpoint();
TEST_ASSERT_EQUAL_INT(0, worker_send_resync_query(&c, w, ep));
twr_rt_pop_and_decode(ep, &decoded_query);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateQuery, decoded_query.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatequerymsg,
decoded_query.body_which);
TEST_ASSERT_NOT_NULL(decoded_query.body.jobstatequerymsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_BURST_N_LOST,
decoded_query.body.jobstatequerymsg->n_total,
"the REAL wire query (Req 2.1, 2.2, 8.1) must list exactly the "
"6 lost jobs, regardless of the burst also containing a 7th, "
"already-terminal one");
for (i = 0; i < TWR_BURST_N_LOST; i++)
saw[i] = 0;
for (i = 0; i < decoded_query.body.jobstatequerymsg->count; i++) {
uint64_t id = decoded_query.body.jobstatequerymsg->jobids[i];
int k;
TEST_ASSERT_FALSE_MESSAGE(id == TWR_JOBID_BURST_ALREADY_DONE,
"the already-terminal job must never appear in the query "
"set (Req 1.2)");
for (k = 0; k < TWR_BURST_N_LOST; k++) {
if (id == lost_ids[k])
saw[k] = 1;
}
}
for (i = 0; i < TWR_BURST_N_LOST; i++) {
TEST_ASSERT_TRUE_MESSAGE(saw[i],
"every one of the 6 lost jobs must be covered by the wire "
"query, by unique ID (Req 2.2, 8.1)");
}
xce_free_request(&decoded_query);
/* ── Worker's real answer: all 6 lost jobs report their TRUE
* outcome (5 finished-success + 1 finished-failure, each with its
* OWN distinct finish_time_ms/exit_code -- Req 3.1, 3.4, 3.5), plus
* the 7th, already-DONE job reported AGAIN as an unqueried extra
* (Req 3.6) -- carrying a deliberately WRONG finish_time_ms, to
* prove reconciliation does not clobber/duplicate what was already
* correctly recorded (this task's own "none duplicated" Observable
* Completion Criterion). All 7 entries travel in ONE page (Req
* 8.1, 8.2) -- this task is not about page-boundary mechanics
* (task 7.4's own separate job). */
{
xce_job_state_entry_t e_lost[TWR_BURST_N_LOST];
xce_job_state_entry_t e_already_done;
xce_job_state_entry_t* entries[TWR_BURST_N_TOTAL];
xce_job_state_report_t report;
for (i = 0; i < TWR_BURST_N_LOST - 1; i++) {
e_lost[i] = (xce_job_state_entry_t){
.jobid = lost_ids[i],
.status = JobStateStatus_finishedOk,
.exit_code = 0,
.finish_time_ms = lost_finish_time_s[i] * 1000ULL,
.queried = 1
};
}
/* The 6th lost job: finished-FAILURE, not success -- a genuine
* mixed-outcome burst (this task's own scope), not a re-proof
* of 7.1's already-established per-outcome routing in
* isolation. */
e_lost[TWR_BURST_N_LOST - 1] = (xce_job_state_entry_t){
.jobid = lost_ids[TWR_BURST_N_LOST - 1],
.status = JobStateStatus_finishedFail,
.exit_code = lost_fail_exit_code,
.finish_time_ms = 0,
.queried = 1
};
e_already_done = (xce_job_state_entry_t){
.jobid = TWR_JOBID_BURST_ALREADY_DONE,
.status = JobStateStatus_finishedOk,
.exit_code = 0,
.finish_time_ms = bogus_reapply_finish_time_s * 1000ULL,
.queried = 0
};
for (i = 0; i < TWR_BURST_N_LOST; i++)
entries[i] = &e_lost[i];
entries[TWR_BURST_N_LOST] = &e_already_done;
report = (xce_job_state_report_t){
.n_total = TWR_BURST_N_TOTAL, .pos = 0,
.count = TWR_BURST_N_TOTAL, .entries = entries
};
TEST_ASSERT_EQUAL_INT(0,
xce_job_state_report_req(ep, c.myhost, &report, NULL, NULL,
0));
}
twr_rt_pop_and_decode(ep, &decoded_report);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded_report.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatereportmsg,
decoded_report.body_which);
TEST_ASSERT_NOT_NULL(decoded_report.body.jobstatereportmsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_BURST_N_TOTAL,
decoded_report.body.jobstatereportmsg->n_total,
"the real wire report must carry all 7 entries in one page "
"(Req 8.1, 8.2)");
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
pthread_mutex_lock(&c.persist.lock);
lsn_before = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
rc = resync_handle_report_page(&c, w,
decoded_report.body.jobstatereportmsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc,
"a single-page report (count == n_total) must complete the "
"report immediately, regardless of the burst's job count "
"(Req 8.1)");
pthread_mutex_lock(&c.persist.lock);
lsn_after = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
xce_free_request(&decoded_report);
/* ── Phase 1 assertions: everything observable before any commit
* round trip. */
for (i = 0; i < TWR_BURST_N_LOST; i++) {
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN,
j_lost[i]->job.state,
"finished-success/failure both stay on the DEFERRED "
"record_job_finish_fact_at/record_job_fail_fact pattern -- "
"state mutates later via apply_committed_entry (Phase 2), "
"not synchronously here, for every one of the 6 lost "
"jobs");
}
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j_done->job.state,
"the already-DONE job must be left completely untouched by "
"its own bogus re-report (Req 4.3, 4.4 -- 'none duplicated')");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(already_done_finish_time_s,
(uint64_t)j_done->job.finish_time,
"the already-DONE job's ORIGINAL finish_time must survive "
"unchanged -- NOT overwritten by the bogus finish_time_ms "
"carried in its own unqueried re-report");
TEST_ASSERT_NOT_NULL(j_done->job.exit_info);
TEST_ASSERT_EQUAL_INT(0, j_done->job.exit_info->exitcode);
TEST_ASSERT_NULL(j_done->job.allocs);
TEST_ASSERT_NULL_MESSAGE(w->resync,
"once every one of the 6 queried jobs has a reconciled "
"outcome, the round must resolve regardless of the burst's "
"count (Req 5.1, 5.2, 8.1)");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_twr_update_hook_fires,
"no EAGER outcome is present in this burst (no unrecognized, "
"no genuinely-unassigned extra) -- the 6 deferred finished-"
"success/failure facts have not applied yet, and the "
"already-DONE job's re-report is correctly a pure no-op with "
"no notify at all (Req 4.3, 4.4)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
lsn_before + (uint64_t)TWR_BURST_N_LOST, lsn_after,
"EXACTLY one durable fact per lost job (5x WAL_JOB_FINISH + 1x "
"WAL_JOB_FAIL) must be appended for this report -- the "
"already-DONE job's duplicate re-report must append NONE, so "
"an exact count (not merely a lower bound) is the genuine "
"'none duplicated' proof: a regression that re-appended a "
"fact for the already-DONE job would silently pass a "
"'>=' check but fails this exact one");
xce_disable_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
if (c.workermgr) dereference(c.workermgr);
if (c.resmgr) dereference(c.resmgr);
if (c.jobmgr) dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
dereference(new_attrs.hosttype);
dereference(new_attrs.hostmodel);
endpoint_fini(ep);
free(ep);
}
/* Phase 2: fresh persist_t + fresh cluster_t (jobmgr/hooksys only),
* recover from disk -- simulates a controller restart, the SAME apply_
* committed_entry() seam a live controller's own commit-apply pump
* would invoke to settle the 6 DEFERRED outcomes above. This is the
* test's core, observable completion criterion: every one of the 6
* lost jobs reaches its correct final state, individually and by its
* OWN distinct worker-reported value (not some other job's from the
* same burst), and the 7th job's already-correct outcome is confirmed
* to have survived completely untouched -- through a real restart
* replay, not just the live path. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t* j2_done = NULL;
job_t* j2_lost[TWR_BURST_N_LOST] = { NULL };
uint64_t lost_ids[TWR_BURST_N_LOST] = {
TWR_JOBID_BURST_LOST_1, TWR_JOBID_BURST_LOST_2,
TWR_JOBID_BURST_LOST_3, TWR_JOBID_BURST_LOST_4,
TWR_JOBID_BURST_LOST_5, TWR_JOBID_BURST_LOST_6
};
int rc, i;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 32, .delta = 16 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
lock(c2.jobmgr, RTTI_LOCK_READ);
key_find(c2.jobmgr, (void*)TWR_JOBID_BURST_ALREADY_DONE,
(void**)&j2_done);
for (i = 0; i < TWR_BURST_N_LOST; i++)
key_find(c2.jobmgr, (void*)lost_ids[i], (void**)&j2_lost[i]);
unlock(c2.jobmgr);
TEST_ASSERT_NOT_NULL(j2_done);
for (i = 0; i < TWR_BURST_N_LOST; i++) {
TEST_ASSERT_NOT_NULL_MESSAGE(j2_lost[i],
"every one of the 6 lost jobs must reconstruct from its "
"own WAL_JOB_SUBMIT fact");
}
/* The core completion criterion (bug-1741, 'none left stuck'):
* every one of the 6 lost jobs, individually, by its own jobid,
* reaches its correct final state AND its OWN distinct
* worker-reported value -- proving no cross-job mixing across the
* burst (Req 8.1). */
for (i = 0; i < TWR_BURST_N_LOST - 1; i++) {
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE,
j2_lost[i]->job.state,
"each lost finished-success job must recover to DONE, "
"regardless of the burst's count (Req 8.1)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lost_finish_time_s[i],
(uint64_t)j2_lost[i]->job.finish_time,
"each lost job must recover ITS OWN worker-reported "
"finish_time -- not another job's from the same burst "
"(cross-job mixing regression check, Req 4.2, 3.5)");
}
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_EXIT,
j2_lost[TWR_BURST_N_LOST - 1]->job.state,
"the one finished-FAILURE job in the burst must recover to "
"EXIT, not DONE, distinctly from its 5 finished-success "
"siblings in the SAME round");
/* Req 4.2 remains documented-unsatisfiable for FAIL's own
* finish_time specifically (bug-1747). Its exit code is recorded
* since bug-1754. */
TEST_ASSERT_NOT_NULL(j2_lost[TWR_BURST_N_LOST - 1]->job.exit_info);
TEST_ASSERT_EQUAL_INT(lost_fail_exit_code,
j2_lost[TWR_BURST_N_LOST - 1]->job.exit_info->exitcode);
/* The 'none duplicated' completion criterion, confirmed to
* survive a restart replay too: the already-DONE job's ORIGINAL
* outcome, not the bogus re-reported one. */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE, j2_done->job.state,
"the already-DONE job must still be DONE after replay");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(already_done_finish_time_s,
(uint64_t)j2_done->job.finish_time,
"the already-DONE job's finish_time after replay must still "
"be its ORIGINAL value -- the bogus re-report's "
"finish_time_ms must never have been durably recorded at "
"all");
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_BURST_N_TOTAL,
g_twr_update_hook_fires,
"replaying the durable facts in this log must reach the "
"scheduler's/apiserver's notify path exactly once per REAL "
"state change -- 6 for the 6 lost jobs' newly-recorded facts, "
"PLUS 1 for the already-DONE job's own ORIGINALLY-seeded "
"WAL_JOB_FINISH fact (recorded before this round even ran, "
"modeling an earlier controller instance's already-committed "
"finish): this bare-persist fixture runs no live apply pump, "
"so THIS replay is the first time apply_committed_entry ever "
"applies that pre-existing fact too, exactly like every other "
"seeded WAL_JOB_SUBMIT in this test -- NOT a second, "
"duplicate application of it. The genuine 'none duplicated' "
"proof is not this count (which is 7 either way) but that the "
"already-DONE job's finish_time below is still its ORIGINAL "
"value, and that Phase 1's own exact LSN-delta check already "
"proved no fact was durably appended for its bogus re-report "
"(Req 7.1-7.3)");
xce_disable_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
printf("test_burst_recovery_unbounded_by_job_count: PASS\n");
}
/* ── Task 7.4: Large job-count pagination correctness (Req 8.1-8.3) ──────
*
* design.md's own "Performance/Load" testing-strategy line: "Reconciliation
* exchange for a worker running an order of magnitude more jobs than a
* single message can carry completes correctly across multiple pages, not
* truncated (Requirement 8.1-8.3)." Tasks 7.1-7.3 all deliberately stayed
* within ONE wire page each (task 7.3's own Implementation Notes:
* "Deliberately kept to a single wire page (page-boundary correctness is
* task 7.4's separate concern)") -- THIS is that separate concern: proving
* BOTH directions of the exchange (controller->worker OPCode_jobStateQuery,
* worker->controller OPCode_jobStateReport) genuinely span MULTIPLE wire
* pages for a large job count, and that resync_handle_report_page()'s
* accumulator (task 6.2) reassembles all of them into exactly one applied
* report -- not merely re-proving task 7.1's four-outcome/task 7.3's burst
* recovery logic at a bigger N.
*
* Real production page-size constants (found by reading the actual call
* sites, not assumed):
* - REPORT direction (worker -> controller): xcecd's real production call
* site (controller_connector.c's handle_jobstatequery ->
* xcecd_resync_handle_query_page -> xcecd_resync_answer_query) passes
* XCECD_RESYNC_REPORT_PKTLIMIT == 100 (controller_connector.c) into
* xce_job_state_report_req() (task 1.4, request.c) -- a REAL, wired,
* production page cap.
* - QUERY direction (controller -> worker): the controller's own real
* production call site, worker_send_resync_query() (worker.c:730),
* hardcodes pktlimit=0 in its xce_job_state_query_req() call --
* xce_job_state_query_req()'s own pktlimit<=0 convention means
* "pktlimit = nrows", i.e. ALWAYS exactly ONE wire page, however many
* jobs are queried. There is no real production constant to cite for
* this direction -- see this task's own CONCERNS (bug-1755). To
* genuinely exercise the QUERY side's already-implemented pagination
* loop (task 1.4) at scale, this test calls xce_job_state_query_req()
* directly (bypassing worker_send_resync_query()'s hardcoded 0),
* supplying the SAME page size as the worker's own real report cap for
* symmetry -- the exact same production function, exercised with a
* real bound instead of an effectively-disabled one.
*
* TWR_LARGE_N_TOTAL (below) is an order of magnitude larger than
* TWR_LARGE_PKTLIMIT (100), yielding a double-digit page count in EACH
* direction, with a genuinely PARTIAL final page (the total, and each of
* the four outcome-group sizes, are deliberately NOT multiples of the page
* limit) -- so several wire pages straddle two different outcome groups, a
* genuine page-boundary-falls-mid-group case, and the very last page of
* each direction is short. Covers all four outcome kinds (running,
* finished-success, finished-failure, unrecognized) so this is not a
* degenerate single-outcome test.
*/
#define TWR_JOBID_LARGE_BASE 900000ULL
#define TWR_LARGE_N_RUNNING 260
#define TWR_LARGE_N_FINOK 245
#define TWR_LARGE_N_FINFAIL 235
#define TWR_LARGE_N_UNREC 302
#define TWR_LARGE_N_TOTAL 1042 /* 260+245+235+302 -- not a multiple of
* TWR_LARGE_PKTLIMIT, so the final wire
* page in each direction is genuinely
* partial (42 entries), not just N full
* pages */
/* Shared page-size cap for both directions in this test -- == the REAL
* production XCECD_RESYNC_REPORT_PKTLIMIT for the report direction; the
* query direction has no real production constant (see header comment
* above / CONCERNS, bug-1755), so this test uses the same value for
* symmetry while directly exercising xce_job_state_query_req()'s already-
* implemented pagination loop. */
#define TWR_LARGE_PKTLIMIT 100
#define TWR_LARGE_N_PAGES \
((TWR_LARGE_N_TOTAL + TWR_LARGE_PKTLIMIT - 1) / TWR_LARGE_PKTLIMIT)
static uint64_t twr_large_jobid(int i)
{
return TWR_JOBID_LARGE_BASE + (uint64_t)i;
}
/* 0 = running, 1 = finished-success, 2 = finished-failure, 3 =
* unrecognized -- see this test's own header comment on why the group
* boundaries are deliberately not page-aligned. */
static int twr_large_group(int i)
{
if (i < TWR_LARGE_N_RUNNING)
return 0;
if (i < TWR_LARGE_N_RUNNING + TWR_LARGE_N_FINOK)
return 1;
if (i < TWR_LARGE_N_RUNNING + TWR_LARGE_N_FINOK + TWR_LARGE_N_FINFAIL)
return 2;
return 3;
}
static void test_large_job_count_pagination_correctness(void)
{
char tmp[256];
uint64_t finok_anchor_s;
strcpy(tmp, "/tmp/xce_twr74_large_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmp));
/* Deliberately far from "now" -- same defensive idiom as every other
* finish-time test in this file. */
finok_anchor_s = (uint64_t)time(NULL) - 3600;
/* Phase 1: real cluster (workermgr + resmgr + jobmgr + hooksys) + real
* disk persist -- drives the REAL rejoin/query-construction chain over
* TWR_LARGE_N_TOTAL jobs, then a REAL, explicitly-bounded wire query
* send and a REAL, real-production-bounded wire report send, both
* genuinely multi-page. Own local cluster_t (not the shared g_twr
* fixture), mirroring every other restart-replay test in this file. */
{
cluster_t c;
kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING,
.nslots = 64, .delta = 16 };
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 1024, .delta = 256 };
persist_config_t cfg;
worker_t* w;
job_t** jobs;
hostattrs_t attrs, new_attrs;
xce_hostconfig_t hconfig;
endpoint_t stack_ep;
endpoint_t* ep;
xce_job_state_entry_t* storage;
xce_job_state_entry_t** entries;
xce_job_state_query_t query;
xce_job_state_report_t report;
int i, rc;
uint64_t lsn_before, lsn_after;
int calls_before, calls_after;
static const char* res_names[TWR_RT_N_RES] = {
"type", "model", "cpuf", "maxcpus", "maxmem",
"maxswap", "maxtmp", "ndisks", "slots"
};
static const int res_valtypes[TWR_RT_N_RES] = {
XCE_VAL_STRING, XCE_VAL_STRING,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC, XCE_VAL_NUMERIC, XCE_VAL_NUMERIC,
XCE_VAL_NUMERIC
};
if (makeString((char*)"twr74-probe") == NULL)
xstring_table_init(64);
if (!g_twr_jobobj_ready) {
TEST_ASSERT_EQUAL_INT(0, job_obj_init(32, 32));
g_twr_jobobj_ready = 1;
}
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c.hooksys, XCE_CONTROLLER_HOOK_MAX));
c.workermgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.workermgr);
c.resmgr = makeInstance(&xkvmgr_type, &str_params);
TEST_ASSERT_NOT_NULL(c.resmgr);
c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c.jobmgr);
for (i = 0; i < TWR_RT_N_RES; i++) {
xce_resource_t rdef;
memset(&rdef, 0, sizeof(rdef));
rdef.name = (char*)res_names[i];
rdef.valtype = res_valtypes[i];
rdef.flags = 0;
rdef.idx = -1;
TEST_ASSERT_NOT_NULL(makeResource(c.resmgr, &rdef));
}
c.myhost_info.name = (char*)"twr74-controller";
c.myhost_info.key = (char*)"twr74-key";
c.myhost = &c.myhost_info;
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
persist_set_workdir(&c.persist, tmp, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&c.persist, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&c.persist));
jobs = (job_t**)malloc(sizeof(job_t*) * TWR_LARGE_N_TOTAL);
TEST_ASSERT_NOT_NULL(jobs);
/* Seed a durable WAL_JOB_SUBMIT fact + a matching in-memory job_t
* for all TWR_LARGE_N_TOTAL jobs -- all RUN, all assigned to the
* SAME worker host, exactly what the controller believes right
* before this rejoin (bypassing dispatch/replay, mirroring every
* sibling restart-replay test in this file). */
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
uint64_t jobid = twr_large_jobid(i);
job_submit_data_t wal_submit;
xce_job_t wal_jdef;
uint8_t* buf = NULL;
size_t sz = 0;
job_submit_data_t submit;
xce_job_t jdef;
memset(&wal_submit, 0, sizeof(wal_submit));
wal_submit.queue = (char*)"q";
wal_submit.user = (char*)"tester";
memset(&wal_jdef, 0, sizeof(wal_jdef));
wal_jdef.jobid = jobid;
wal_jdef.state = JOB_STAT_RUN;
wal_jdef.submit = &wal_submit;
TEST_ASSERT_EQUAL_INT(0, twr_encode_job(&wal_jdef, &buf, &sz));
TEST_ASSERT_EQUAL_INT(0,
persist_wal_async(&c.persist, WAL_JOB_SUBMIT, buf,
(uint32_t)sz));
free(buf);
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)"q";
submit.user = (char*)"tester";
memset(&jdef, 0, sizeof(jdef));
jdef.jobid = jobid;
jdef.state = JOB_STAT_RUN;
jdef.submit = &submit;
jobs[i] = makeJob(c.jobmgr, &jdef);
TEST_ASSERT_NOT_NULL(jobs[i]);
jobs[i]->job.allocs
= twr_make_test_alloc_for_host(jobid, "resync-large-host");
TEST_ASSERT_NOT_NULL(jobs[i]->job.allocs);
}
/* ── Join: rejoin_worker (the REAL join entry point) arms the round
* over all TWR_LARGE_N_TOTAL non-terminal jobs -- Req 1.1, 2.1, and
* the "no artificial cap" proof (Req 8.1) that resync_collect_
* query_jobids' realloc-doubling growth (resync.c) genuinely has no
* fixed-size ceiling. */
memset(&attrs, 0, sizeof(attrs));
attrs.hosttype = makeString("LINUX");
attrs.hostmodel = makeString("x86");
attrs.cpufactor = 1.0f;
attrs.maxcpus = 4;
attrs.maxmem = 16;
attrs.maxswap = 32;
attrs.maxtmp = 64;
attrs.ndisks = 1;
{
xce_worker_t host;
memset(&host, 0, sizeof(host));
host.name = (char*)"resync-large-host";
host.attrs = &attrs;
w = makeWorker(c.workermgr, &host);
}
TEST_ASSERT_NOT_NULL(w);
memset(&new_attrs, 0, sizeof(new_attrs));
new_attrs.hosttype = makeString("LINUX");
new_attrs.hostmodel = makeString("x86");
new_attrs.cpufactor = 1.0f;
new_attrs.maxcpus = 4;
new_attrs.maxmem = 16;
new_attrs.maxswap = 32;
new_attrs.maxtmp = 64;
new_attrs.ndisks = 1;
memset(&hconfig, 0, sizeof(hconfig));
hconfig.hosttype = (char*)"!";
hconfig.hostmodel = (char*)"!";
hconfig.mxj = 4;
memset(&stack_ep, 0, sizeof(stack_ep));
rc = rejoin_worker(&c, w, &stack_ep, &new_attrs, &hconfig);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(w->resync,
"a rejoin with TWR_LARGE_N_TOTAL known non-terminal jobs on "
"this host must arm a reconciliation round (Req 1.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_LARGE_N_TOTAL,
w->resync->n_queried_jobids,
"the round's query set must match exactly the host's non-"
"terminal jobs, with no artificial cap on how many resync_"
"arm_round will collect (Req 1.1, 2.1, 8.1)");
TEST_ASSERT_TRUE(IS_WORKER_RECONCILING(w->host.status));
ep = twr_rt_new_test_endpoint();
/* ── QUERY direction: a REAL wire-encoded xce_job_state_query_req()
* send, called directly (not via worker_send_resync_query(), which
* hardcodes pktlimit=0 -- see this test's own header comment and
* CONCERNS) with an explicit page size, genuinely producing
* multiple wire pages for this job count. */
query = (xce_job_state_query_t){
.n_total = w->resync->n_queried_jobids,
.pos = 0,
.count = w->resync->n_queried_jobids,
.jobids = w->resync->queried_jobids
};
TEST_ASSERT_EQUAL_INT(0,
xce_job_state_query_req(ep, c.myhost, &query, NULL, NULL,
TWR_LARGE_PKTLIMIT));
{
bool seen_query[TWR_LARGE_N_TOTAL];
int running_total = 0;
int query_pages = 0;
memset(seen_query, 0, sizeof(seen_query));
while (running_total < TWR_LARGE_N_TOTAL) {
xce_request_t decoded_query;
int j;
twr_rt_pop_and_decode(ep, &decoded_query);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateQuery,
decoded_query.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatequerymsg,
decoded_query.body_which);
TEST_ASSERT_NOT_NULL(decoded_query.body.jobstatequerymsg);
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_LARGE_N_TOTAL,
decoded_query.body.jobstatequerymsg->n_total,
"Req 2.1: every query page must carry the SAME full "
"n_total, not a per-page total");
TEST_ASSERT_EQUAL_INT_MESSAGE(running_total,
decoded_query.body.jobstatequerymsg->pos,
"Req 8.3: each page's pos must pick up exactly where "
"the previous page's count left off -- no gap, no "
"overlap");
for (j = 0; j < decoded_query.body.jobstatequerymsg->count;
j++) {
uint64_t jobid
= decoded_query.body.jobstatequerymsg->jobids[j];
uint64_t idx = jobid - TWR_JOBID_LARGE_BASE;
TEST_ASSERT_TRUE_MESSAGE(
idx < (uint64_t)TWR_LARGE_N_TOTAL,
"a jobid appearing on the wire query must be one "
"of the armed round's own jobids");
TEST_ASSERT_FALSE_MESSAGE(seen_query[idx],
"Req 8.3: no jobid may be sent twice across query "
"pages");
seen_query[idx] = true;
}
running_total
+= decoded_query.body.jobstatequerymsg->count;
query_pages++;
xce_free_request(&decoded_query);
}
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_LARGE_N_PAGES, query_pages,
"Req 8.1-8.3: an order-of-magnitude-larger-than-one-page "
"query must genuinely span many wire pages, including a "
"partial final page, not silently collapse to one (the "
"real production worker_send_resync_query() call site "
"would collapse to one -- see CONCERNS, bug-1755 -- this "
"assertion is against xce_job_state_query_req()'s own "
"pagination mechanism, exercised directly)");
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
TEST_ASSERT_TRUE_MESSAGE(seen_query[i],
"Req 8.3: every armed jobid must appear exactly once "
"across all query pages -- none silently dropped past "
"a page boundary");
}
}
/* ── Worker's answer: a REAL wire-encoded-then-decoded OPCode_
* jobStateReport, sent at the REAL production report page size
* (XCECD_RESYNC_REPORT_PKTLIMIT, controller_connector.c) --
* genuinely many wire pages -- covering all four outcome kinds,
* fed page-by-page into resync_handle_report_page() (task 6.2's
* real reassembly entry point), never resync_apply_report()
* directly. */
storage = (xce_job_state_entry_t*)malloc(
sizeof(xce_job_state_entry_t) * TWR_LARGE_N_TOTAL);
entries = (xce_job_state_entry_t**)malloc(
sizeof(xce_job_state_entry_t*) * TWR_LARGE_N_TOTAL);
TEST_ASSERT_NOT_NULL(storage);
TEST_ASSERT_NOT_NULL(entries);
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
xce_job_state_entry_t* e = &storage[i];
memset(e, 0, sizeof(*e));
e->jobid = twr_large_jobid(i);
e->queried = 1;
switch (twr_large_group(i)) {
case 0:
e->status = JobStateStatus_running;
break;
case 1: {
int local = i - TWR_LARGE_N_RUNNING;
uint64_t fin_s = finok_anchor_s - (uint64_t)(local * 3);
e->status = JobStateStatus_finishedOk;
e->finish_time_ms = fin_s * 1000ULL;
break;
}
case 2: {
int local = i - TWR_LARGE_N_RUNNING - TWR_LARGE_N_FINOK;
e->status = JobStateStatus_finishedFail;
e->exit_code = 200 + local;
break;
}
default:
e->status = JobStateStatus_unrecognized;
break;
}
entries[i] = e;
}
report = (xce_job_state_report_t){
.n_total = TWR_LARGE_N_TOTAL,
.pos = 0,
.count = TWR_LARGE_N_TOTAL,
.entries = entries
};
TEST_ASSERT_EQUAL_INT(0,
xce_job_state_report_req(ep, c.myhost, &report, NULL, NULL,
TWR_LARGE_PKTLIMIT));
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
pthread_mutex_lock(&c.persist.lock);
lsn_before = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
resync_apply_report_call_count_reset_for_test();
calls_before = resync_apply_report_call_count_for_test();
{
int running_total = 0;
int report_pages = 0;
while (running_total < TWR_LARGE_N_TOTAL) {
xce_request_t decoded_report;
int rc_page;
twr_rt_pop_and_decode(ep, &decoded_report);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport,
decoded_report.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatereportmsg,
decoded_report.body_which);
TEST_ASSERT_NOT_NULL(decoded_report.body.jobstatereportmsg);
running_total
+= decoded_report.body.jobstatereportmsg->count;
report_pages++;
rc_page = resync_handle_report_page(
&c, w, decoded_report.body.jobstatereportmsg);
if (running_total < TWR_LARGE_N_TOTAL) {
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc_page,
"a non-final report page must not complete the "
"report");
} else {
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc_page,
"the final (partial) report page must complete "
"the report");
}
xce_free_request(&decoded_report);
}
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_LARGE_N_PAGES, report_pages,
"Req 8.1-8.3: at the real production per-page limit, this "
"job count must genuinely span many wire pages, including "
"a partial final page");
}
calls_after = resync_apply_report_call_count_for_test();
TEST_ASSERT_EQUAL_INT_MESSAGE(1, calls_after - calls_before,
"resync_apply_report must be invoked EXACTLY ONCE for the "
"whole large, many-page report -- not once per page (task "
"6.2's own structural proof, generalized from 2 pages to "
"double digits)");
pthread_mutex_lock(&c.persist.lock);
lsn_after = c.persist.current_lsn;
pthread_mutex_unlock(&c.persist.lock);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
lsn_before
+ (uint64_t)(TWR_LARGE_N_FINOK + TWR_LARGE_N_FINFAIL
+ TWR_LARGE_N_UNREC),
lsn_after,
"EXACTLY one durable fact per non-running job (finished-"
"success x WAL_JOB_FINISH + finished-failure x WAL_JOB_FAIL + "
"unrecognized x WAL_JOB_UPDATE), and NONE for the still-"
"running jobs -- an exact count (not merely a lower bound) is "
"the genuine 'none dropped, none duplicated across page "
"boundaries' proof");
TEST_ASSERT_EQUAL_INT_MESSAGE(TWR_LARGE_N_UNREC,
g_twr_update_hook_fires,
"Req 7.1-7.3: the EAGER unrecognized-to-pending transition "
"must reach the scheduler's/apiserver's notify path "
"synchronously, exactly once per unrecognized job, regardless "
"of which page it happened to land on");
TEST_ASSERT_NULL_MESSAGE(w->resync,
"once every queried job across every report page has a "
"reconciled outcome, the round must resolve (Req 5.1, 5.2)");
TEST_ASSERT_FALSE_MESSAGE(IS_WORKER_RECONCILING(w->host.status),
"XCE_WORKER_RECONCILING must clear once the round resolves");
/* Phase 1 end-to-end per-job assertions (Req 8.1-8.3: EVERY job
* accounted for, not a sample) -- everything observable before any
* commit round-trip. */
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
switch (twr_large_group(i)) {
case 0:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN,
jobs[i]->job.state,
"still-running (queried) must be left exactly as-is");
TEST_ASSERT_NOT_NULL(jobs[i]->job.allocs);
break;
case 1:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN,
jobs[i]->job.state,
"finished-success stays DEFERRED (record_job_finish_"
"fact_at) -- mutates later via apply_committed_entry, "
"not synchronously here");
break;
case 2:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN,
jobs[i]->job.state,
"finished-failure stays DEFERRED (on_job_fail/record_"
"job_fail_fact), same as finished-success");
break;
default:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND,
jobs[i]->job.state,
"unrecognized reconciles EAGERLY -- PEND "
"synchronously, before any commit round-trip");
TEST_ASSERT_NULL_MESSAGE(jobs[i]->job.allocs,
"bug-1631: reconciled-to-pending must release the "
"allocation synchronously, at scale, across page "
"boundaries");
break;
}
}
xce_disable_hook(&c.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
TEST_ASSERT_EQUAL_INT(0, persist_flush(&c.persist));
persist_stop(&c.persist);
persist_fini(&c.persist);
if (c.workermgr) dereference(c.workermgr);
if (c.resmgr) dereference(c.resmgr);
if (c.jobmgr) dereference(c.jobmgr);
xce_hook_fini(&c.hooksys);
dereference(new_attrs.hosttype);
dereference(new_attrs.hostmodel);
endpoint_fini(ep);
free(ep);
free(jobs);
free(storage);
free(entries);
}
/* Phase 2: fresh persist_t + fresh cluster_t (jobmgr/hooksys only),
* recover from disk -- simulates a controller restart, settling the two
* DEFERRED outcomes (finished-success/failure) across every page they
* happened to land on, and re-confirming the EAGER unrecognized->
* pending outcome also round-trips through a real WAL replay, not just
* the live eager-mutation path -- for EVERY one of the TWR_LARGE_N_
* TOTAL jobs, individually, not a sample. */
{
persist_t* p2 = twr_make_fresh_persist(tmp);
cluster_t c2;
job_t** jobs2;
int rc, i;
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
.nslots = 1024, .delta = 256 };
TEST_ASSERT_NOT_NULL(p2);
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0,
xce_hook_init(&c2.hooksys, XCE_CONTROLLER_HOOK_MAX));
c2.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(c2.jobmgr);
g_twr_update_hook_fires = 0;
TEST_ASSERT_EQUAL_INT(0,
xce_add_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook, NULL));
p2->cluster = &c2;
rc = persist_recover(p2);
TEST_ASSERT_EQUAL_INT(0, rc);
jobs2 = (job_t**)malloc(sizeof(job_t*) * TWR_LARGE_N_TOTAL);
TEST_ASSERT_NOT_NULL(jobs2);
lock(c2.jobmgr, RTTI_LOCK_READ);
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
jobs2[i] = NULL;
key_find(c2.jobmgr, (void*)twr_large_jobid(i),
(void**)&jobs2[i]);
}
unlock(c2.jobmgr);
for (i = 0; i < TWR_LARGE_N_TOTAL; i++) {
TEST_ASSERT_NOT_NULL_MESSAGE(jobs2[i],
"restart replay must reconstruct every job via its own "
"WAL_JOB_SUBMIT fact");
switch (twr_large_group(i)) {
case 0:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_RUN,
jobs2[i]->job.state,
"still-running: no fact was ever recorded, so replay "
"leaves it exactly as WAL_JOB_SUBMIT alone "
"reconstructs it");
break;
case 1: {
int local = i - TWR_LARGE_N_RUNNING;
uint64_t fin_s = finok_anchor_s - (uint64_t)(local * 3);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_DONE,
jobs2[i]->job.state,
"finished-success: the DEFERRED record_job_finish_"
"fact_at fact must apply to DONE on replay");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(fin_s,
(uint64_t)jobs2[i]->job.finish_time,
"each finished-success job must recover ITS OWN "
"worker-reported finish_time -- no cross-job mixing "
"across a page boundary");
break;
}
case 2:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_EXIT,
jobs2[i]->job.state,
"finished-failure: the DEFERRED on_job_fail/record_"
"job_fail_fact fact must apply to EXIT on replay");
break;
default:
TEST_ASSERT_EQUAL_UINT32_MESSAGE(JOB_STAT_PEND,
jobs2[i]->job.state,
"unrecognized: the EAGER record_job_reconcile_"
"pending_fact's durable WAL_JOB_UPDATE fact must ALSO "
"reconstruct correctly on replay");
TEST_ASSERT_NULL(jobs2[i]->job.allocs);
break;
}
}
TEST_ASSERT_EQUAL_INT_MESSAGE(
TWR_LARGE_N_FINOK + TWR_LARGE_N_FINFAIL + TWR_LARGE_N_UNREC,
g_twr_update_hook_fires,
"Req 7.1-7.3: replaying the durable facts this reconciliation "
"round produced must reach the scheduler's/apiserver's notify "
"path for EVERY actual state change -- finished-success, "
"finished-failure, and unrecognized-to-pending -- exactly "
"once each, across every job (still-running recorded no fact, "
"and WAL_JOB_SUBMIT fires HOOK_DETAIL_JOB_NEW, not JOB_UPDATE, "
"so neither adds to this count)");
xce_disable_hook(&c2.hooksys, XCE_CONTROLLER_HOOK_JOB,
twr_job_update_spy_hook);
xce_hook_fini(&c2.hooksys);
if (c2.jobmgr) dereference(c2.jobmgr);
twr_teardown_persist(p2);
free(jobs2);
}
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmp);
if (system(cmd) != 0)
fprintf(stderr, "cleanup warning: rm -rf %s failed\n", tmp);
}
printf("test_large_job_count_pagination_correctness: PASS\n");
}
/* ── Test group entry point ───────────────────────────────────────────── */
void test_worker_resync(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_reconcile_pending_applies_releases_allocs_and_notifies);
RUN_TEST(test_reconcile_pending_noop_on_already_pending_job);
RUN_TEST(test_reconcile_pending_rejects_terminal_job);
RUN_TEST(test_reconcile_assign_applies_running_no_allocs_and_notifies);
RUN_TEST(test_reconcile_assign_survives_restart_replay);
RUN_TEST(test_resync_arm_round_query_set_matches_exactly);
RUN_TEST(test_resync_arm_round_no_jobs_is_noop);
RUN_TEST(test_resync_arm_round_fresh_token_supersedes_prior);
RUN_TEST(test_resync_reconciling_flag_set_after_arm_cleared_after_resolve);
RUN_TEST(test_resync_resolve_round_notifies_worker_update);
RUN_TEST(test_resync_timeout_resolves_round_without_blocking);
RUN_TEST(test_apply_report_all_five_outcomes_updates_job_table);
RUN_TEST(test_apply_report_duplicate_reapplication_is_noop);
RUN_TEST(test_apply_report_stale_round_token_is_discarded);
RUN_TEST(test_apply_report_partial_report_does_not_resolve_round);
RUN_TEST(test_apply_report_unqueried_extra_already_tracked_is_noop);
RUN_TEST(
test_apply_report_finished_success_uses_worker_reported_finish_time);
RUN_TEST(
test_apply_report_unqueried_extra_unassigned_pend_job_gets_assigned);
RUN_TEST(test_handle_report_page_single_page_baseline);
RUN_TEST(test_handle_report_page_multi_page_reassembles_and_applies_once);
RUN_TEST(test_handle_report_page_stale_round_discarded_when_superseded);
RUN_TEST(test_handle_report_page_malformed_bounds_rejected_and_recovers);
RUN_TEST(test_full_rejoin_reconciliation_round_trip);
RUN_TEST(test_failover_during_job_recovers_lost_finish);
RUN_TEST(test_burst_recovery_unbounded_by_job_count);
RUN_TEST(test_large_job_count_pagination_correctness);
}