test(worker-rejoin-resync): task 7.3 - burst recovery unbounded by count
Add test_burst_recovery_unbounded_by_job_count, reproducing bug-1741's exact original shape (7 jobs on one host, 6 lost finish reports) in one reconciliation round and one wire page: 5 finished-success + 1 finished-failure recover correctly and distinctly, while an already- DONE job re-reported as an unqueried extra (with a deliberately wrong finish time) is proven structurally unchanged - exact +6 WAL LSN delta, not >=, so a duplicate append is genuinely caught rather than silently tolerated. Test-only, no production changes; deliberately kept to a single page since page-boundary correctness is task 7.4's separate concern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y4M5MJM2zK6nU43da267Ku
This commit is contained in:
parent
bceca0c990
commit
165fc1586a
2 changed files with 634 additions and 1 deletions
|
|
@ -3436,6 +3436,637 @@ static void test_failover_during_job_recovers_lost_finish(void)
|
|||
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, pre-existing, systemic, out
|
||||
* of this task's boundary -- see tasks 7.1/7.2's own identical
|
||||
* finding); exit_info stays NULL for the same pre-existing reason,
|
||||
* asserted honestly rather than as a wrong "correct" value this
|
||||
* codebase cannot actually produce. */
|
||||
TEST_ASSERT_NULL(j2_lost[TWR_BURST_N_LOST - 1]->job.exit_info);
|
||||
|
||||
/* 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");
|
||||
}
|
||||
|
||||
/* ── Test group entry point ───────────────────────────────────────────── */
|
||||
|
||||
void test_worker_resync(void)
|
||||
|
|
@ -3466,4 +4097,5 @@ void test_worker_resync(void)
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue