feat(worker-rejoin-resync): task 4.2 - worker answers job-state query with extras

Classify each queried job ID as running/finished-ok/finished-fail/
unrecognized (Req 3.1-3.3, 3.6), report unqueried running/recently-
finished jobs as extras, and send both via task 1.4's paginated wire
helper (Req 8.2, 8.3). Composes correctly with the already-implemented
controller-side consumer (resync_apply_report).

Adds xcecd_finished_job_retention_snapshot() to xcecd_worker_utils, a
small additive companion needed to enumerate all retained jobs for the
extras set (task 4.1 only exposed single-jobid lookup).

Folds the still-open OPCode_jobStateQuery receive-dispatch wiring gap
into task 4.3's scope in tasks.md, since 4.3 already needs the real
receive path to implement its post-join gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4M5MJM2zK6nU43da267Ku
This commit is contained in:
Rongsong Shen 2026-09-01 16:35:28 +00:00
parent e31012e8a7
commit cfe58c3dcf
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
7 changed files with 1084 additions and 4 deletions

View file

@ -0,0 +1,96 @@
#ifndef XCECD_RESYNC_H
#define XCECD_RESYNC_H
#include <stdint.h>
#include "netio.h"
#include "request.h"
#include "xcecd_worker_utils.h"
/*
* worker-rejoin-resync task 4.2 (Requirements 3.1, 3.2, 3.3, 3.6, 8.2, 8.3):
* answer the controller's job-state query from the worker's own live
* running list (xcecd_worker_t, xcecd_worker_utils.c) and the new
* finished-job retention (xcecd_worker_utils.c, task 4.1), then report any
* additional job the worker is currently running or has recently finished
* that was NOT part of the query (Requirement 3.6's "extras").
*
* Boundary/scope: this module implements the pure classify/build/send
* logic only.
* - It does NOT wire OPCode_jobStateQuery's receipt into the worker's
* dispatch loop (controller_connector.c's myreq_handler switch) --
* that opcode-dispatch wiring is not claimed by any task in
* tasks.md's current text (task 6.1 is controller-side send +
* config-window delivery; task 6.2 is controller-side report
* ingestion). Whatever task performs that wiring must also
* reassemble a query that itself arrives across multiple incoming
* pages (Req 8.1) into one flat jobids array before calling
* xcecd_resync_answer_query() below -- this module takes an
* already-assembled list, it does not itself track query pages.
* - It does NOT implement the Requirement 3.8 "resync_pending" gate
* (task 4.3's boundary, same file) that delays the worker's own
* post-join operation until the report has fully sent.
*/
/* Classify one job ID against the worker's live running list, falling
* back to the finished-job retention, per Requirements 3.1/3.3:
* JobStateStatus_running (found on the live list with worker_status ==
* WORKER_RUNNING -- i.e. not yet exited), JobStateStatus_finishedOk/
* finishedFail (found in retention, per its exit_status), or
* JobStateStatus_unrecognized (found in neither -- an indeterminate case
* is reported as unrecognized, never guessed, matching Requirement 3.3
* verbatim). `out` is fully populated except `queried`, which the caller
* sets afterward (1 for a queried job ID, 0 for an extra) -- this
* function only classifies, it does not know which set its caller is
* building. now_ms is the caller-captured wall-clock time, passed
* through verbatim to xcecd_finished_job_lookup(). */
void xcecd_resync_classify_job(xcecd_worker_t* worker_list, uint64_t jobid,
uint64_t now_ms, xce_job_state_entry_t* out);
/*
* Build the full set of report entries for a job-state query: one
* classified entry per ID in `jobids`, in the same order given, with
* queried=1 (Requirement 3.2 -- every queried ID gets exactly one
* answer, none silently skipped), followed by one entry with queried=0
* for every job the worker is currently running or has a live retained
* finished outcome for that was NOT present in `jobids` (Requirement
* 3.6).
*
* On success (0), *out_entries is a heap-allocated array of
* *out_nentries heap-allocated xce_job_state_entry_t* pointers --
* borrow-compatible with xce_job_state_report_t.entries, the exact shape
* xce_job_state_report_req() (task 1.4) expects. A query with no jobids
* and no extras yields *out_entries == NULL, *out_nentries == 0 (a valid,
* empty result -- xcecd_resync_answer_query() still sends one empty
* report page for it, matching xce_job_state_report_req()'s own
* "an empty page is still a complete exchange" contract).
*
* Free the result with xcecd_resync_free_entries(). Returns -EINVAL if
* out_entries/out_nentries is NULL, -ENOMEM on allocation failure (with
* all partial state already released).
*/
int xcecd_resync_build_report(xcecd_worker_t* worker_list,
const uint64_t* jobids, int njobids,
uint64_t now_ms,
xce_job_state_entry_t*** out_entries,
int* out_nentries);
/* Frees an entries array produced by xcecd_resync_build_report(). Safe to
* call with entries == NULL / nentries == 0. */
void xcecd_resync_free_entries(xce_job_state_entry_t** entries, int nentries);
/*
* Answer an already-reassembled job-state query end-to-end: classify
* every queried ID, append the extras (xcecd_resync_build_report()
* above), and send the whole result to the controller as one or more
* paginated OPCode_jobStateReport messages via xce_job_state_report_req()
* (task 1.4's wire helper -- Requirements 8.2, 8.3). `pktlimit` is passed
* straight through to xce_job_state_report_req() (<=0 sends everything in
* a single page). Returns 0 on success, or the first negative rc either
* xcecd_resync_build_report() or xce_job_state_report_req() returns.
*/
int xcecd_resync_answer_query(xcecd_worker_t* worker_list, endpoint_t* ep,
xce_peerinfo_t* from, const uint64_t* jobids,
int njobids, uint64_t now_ms, int pktlimit);
#endif /* XCECD_RESYNC_H */

View file

@ -123,6 +123,24 @@ void xcecd_finished_job_retention_set_window_ms(uint64_t window_ms);
/* Returns the currently effective retention window in milliseconds. */
uint64_t xcecd_finished_job_retention_get_window_ms(void);
/* worker-rejoin-resync task 4.2 (Requirement 3.6): copies every currently
* retained, non-expired (as of now_ms) finished-job entry into a freshly
* heap-allocated array of xcecd_finished_job_t VALUES (not linked -- each
* copy's `next` is zeroed), so a caller outside this file (xcecd_resync.c)
* can find which recently-finished jobs are not part of a controller
* query and report them as extras. Task 4.1's own public API only
* supported single-jobid lookup (xcecd_finished_job_lookup below), which
* cannot answer "what is currently retained" -- this is the minimal
* additive companion this task needs; it does not change any existing
* function's behavior. Lazily prunes expired entries first, exactly like
* xcecd_finished_job_lookup(). On success (0) *out is the array and
* *out_n its length (0 with *out left NULL when nothing is retained);
* caller frees *out with free(). Returns -EINVAL if out/out_n is NULL,
* -ENOMEM on allocation failure. */
int xcecd_finished_job_retention_snapshot(uint64_t now_ms,
xcecd_finished_job_t** out,
int* out_n);
/* Test-only: discard all retained entries and reset the window to the
* compiled-in default, so tests don't leak state across TEST_* functions. */
void xcecd_finished_job_retention_reset(void);

View file

@ -0,0 +1,218 @@
#include "xcecd_resync.h"
#include <errno.h>
#include <stdlib.h>
#include <string.h>
/* worker-rejoin-resync task 4.2 (Requirements 3.1, 3.2, 3.3, 3.6, 8.2, 8.3).
* See xcecd_resync.h for the public API and its boundary/scope note. */
static bool jobid_in_array(const uint64_t* arr, int n, uint64_t jobid)
{
int i;
for (i = 0; i < n; i++) {
if (arr[i] == jobid) {
return true;
}
}
return false;
}
void xcecd_resync_classify_job(xcecd_worker_t* worker_list, uint64_t jobid,
uint64_t now_ms, xce_job_state_entry_t* out)
{
xcecd_worker_t* w;
xcecd_finished_job_t fin;
memset(out, 0, sizeof(*out));
out->jobid = jobid;
/* Requirement 3.1/3.3: check the live running list first. */
w = find_worker_by_jobid(worker_list, jobid);
if (w != NULL && w->worker_status == WORKER_RUNNING) {
out->status = JobStateStatus_running;
return;
}
/* Not (still) running -- fall back to the bounded finished-job
* retention (task 4.1). exit_status is WORKER_DONE (success) or
* WORKER_EXITED (failure), per xcecd_worker_utils.c's own
* update_worker_status() hook -- translated 1:1, no new judgment
* call. finish_time_ms is the worker-reported real finish time
* (Requirement 3.5), not the time this function runs. */
memset(&fin, 0, sizeof(fin));
if (xcecd_finished_job_lookup(jobid, now_ms, &fin)) {
out->status = (fin.exit_status == WORKER_DONE)
? JobStateStatus_finishedOk
: JobStateStatus_finishedFail;
out->exit_code = (int32_t)fin.exit_code;
out->finish_time_ms = fin.finish_time_ms;
return;
}
/* Found in neither -- an indeterminate/unknown case is reported as
* unrecognized rather than guessed (Requirement 3.3). */
out->status = JobStateStatus_unrecognized;
}
int xcecd_resync_build_report(xcecd_worker_t* worker_list,
const uint64_t* jobids, int njobids,
uint64_t now_ms,
xce_job_state_entry_t*** out_entries,
int* out_nentries)
{
xce_job_state_entry_t* storage = NULL;
xce_job_state_entry_t** ptrs = NULL;
xcecd_worker_t* w;
xcecd_finished_job_t* snapshot = NULL;
int nsnapshot = 0;
int nrunning_extra = 0;
int nfinished_extra = 0;
int total;
int idx;
int i;
int rc;
if (out_entries == NULL || out_nentries == NULL) {
return -EINVAL;
}
*out_entries = NULL;
*out_nentries = 0;
if (njobids < 0) {
njobids = 0;
}
/* Snapshot retention once up front (task 4.2 addition to
* xcecd_worker_utils.c -- see its header comment) so the counting
* pass and the building pass below see the same set. */
rc = xcecd_finished_job_retention_snapshot(now_ms, &snapshot, &nsnapshot);
if (rc != 0) {
return rc;
}
/* Count extras first so the entries array can be sized exactly once. */
for (w = worker_list; w != NULL; w = w->next) {
if (w->worker_status == WORKER_RUNNING
&& !jobid_in_array(jobids, njobids, w->job_info.jobid)) {
nrunning_extra++;
}
}
for (i = 0; i < nsnapshot; i++) {
if (!jobid_in_array(jobids, njobids, snapshot[i].jobid)) {
nfinished_extra++;
}
}
total = njobids + nrunning_extra + nfinished_extra;
if (total == 0) {
free(snapshot);
return 0;
}
storage = (xce_job_state_entry_t*)calloc((size_t)total, sizeof(*storage));
ptrs = (xce_job_state_entry_t**)calloc((size_t)total, sizeof(*ptrs));
if (storage == NULL || ptrs == NULL) {
free(storage);
free(ptrs);
free(snapshot);
return -ENOMEM;
}
idx = 0;
/* Requirement 3.2: every queried ID gets exactly one entry, in the
* order given -- none silently skipped. */
for (i = 0; i < njobids; i++) {
xcecd_resync_classify_job(worker_list, jobids[i], now_ms,
&storage[idx]);
storage[idx].queried = 1;
ptrs[idx] = &storage[idx];
idx++;
}
/* Requirement 3.6: extras -- jobs the worker is currently running
* that were not part of the query. */
for (w = worker_list; w != NULL; w = w->next) {
if (w->worker_status == WORKER_RUNNING
&& !jobid_in_array(jobids, njobids, w->job_info.jobid)) {
storage[idx].jobid = w->job_info.jobid;
storage[idx].status = JobStateStatus_running;
storage[idx].queried = 0;
ptrs[idx] = &storage[idx];
idx++;
}
}
/* Requirement 3.6: extras -- jobs the worker has recently finished
* that were not part of the query. */
for (i = 0; i < nsnapshot; i++) {
if (!jobid_in_array(jobids, njobids, snapshot[i].jobid)) {
storage[idx].jobid = snapshot[i].jobid;
storage[idx].status = (snapshot[i].exit_status == WORKER_DONE)
? JobStateStatus_finishedOk
: JobStateStatus_finishedFail;
storage[idx].exit_code = (int32_t)snapshot[i].exit_code;
storage[idx].finish_time_ms = snapshot[i].finish_time_ms;
storage[idx].queried = 0;
ptrs[idx] = &storage[idx];
idx++;
}
}
free(snapshot);
*out_entries = ptrs;
*out_nentries = total;
return 0;
}
void xcecd_resync_free_entries(xce_job_state_entry_t** entries, int nentries)
{
if (entries == NULL) {
return;
}
/* `storage` in xcecd_resync_build_report() is one contiguous
* allocation -- entries[0] points at storage[0], so freeing it frees
* the whole block. */
if (nentries > 0) {
free(entries[0]);
}
free(entries);
}
int xcecd_resync_answer_query(xcecd_worker_t* worker_list, endpoint_t* ep,
xce_peerinfo_t* from, const uint64_t* jobids,
int njobids, uint64_t now_ms, int pktlimit)
{
xce_job_state_entry_t** entries = NULL;
int nentries = 0;
xce_job_state_report_t report;
int rc;
rc = xcecd_resync_build_report(worker_list, jobids, njobids, now_ms,
&entries, &nentries);
if (rc != 0) {
return rc;
}
memset(&report, 0, sizeof(report));
report.n_total = nentries;
report.pos = 0;
report.count = nentries;
report.entries = entries;
rc = xce_job_state_report_req(ep, from, &report, NULL, NULL, pktlimit);
xcecd_resync_free_entries(entries, nentries);
return rc;
}

View file

@ -582,6 +582,49 @@ uint64_t xcecd_finished_job_retention_get_window_ms(void)
return g_finished_job_retention_window_ms;
}
int xcecd_finished_job_retention_snapshot(uint64_t now_ms,
xcecd_finished_job_t** out,
int* out_n)
{
xcecd_finished_job_t* e;
xcecd_finished_job_t* copy;
int n = 0;
if (out == NULL || out_n == NULL) {
return -EINVAL;
}
*out = NULL;
*out_n = 0;
finished_job_retention_prune(now_ms, g_finished_job_retention_window_ms);
for (e = g_finished_job_retention; e != NULL; e = e->next) {
n++;
}
if (n == 0) {
return 0;
}
copy = (xcecd_finished_job_t*)calloc((size_t)n, sizeof(*copy));
if (copy == NULL) {
return -ENOMEM;
}
n = 0;
for (e = g_finished_job_retention; e != NULL; e = e->next) {
copy[n] = *e;
copy[n].next = NULL;
n++;
}
*out = copy;
*out_n = n;
return 0;
}
void xcecd_finished_job_retention_reset(void)
{
xcecd_finished_job_t* e = g_finished_job_retention;

View file

@ -107,3 +107,19 @@ test('worker finished_job_retention test', finished_job_retention_test,
suite: 'worker',
timeout: 60,
env: ['ASAN_OPTIONS=detect_leaks=0'])
# worker-rejoin-resync task 4.2 (Requirements 3.1, 3.2, 3.3, 3.6, 8.2, 8.3):
# answer the controller's job-state query (running list + task 4.1's
# finished-job retention), including extras -- white-box #include of both
# ../src/xcecd_worker_utils.c and ../src/xcecd_resync.c, same convention
# as finished_job_retention_test above.
xcecd_resync_test = executable('xcecd_resync_test',
'test_xcecd_resync.c',
include_directories: [worker_test_incdir],
dependencies: worker_test_deps,
install: false)
test('worker xcecd_resync test', xcecd_resync_test,
suite: 'worker',
timeout: 60,
env: ['ASAN_OPTIONS=detect_leaks=0'])

View file

@ -0,0 +1,686 @@
/*
* test_xcecd_resync.c -- worker-rejoin-resync task 4.2 (Requirements 3.1,
* 3.2, 3.3, 3.6, 8.2, 8.3): answer the controller's job-state query from
* the worker's own live running list and the new finished-job retention
* (task 4.1), including extra jobs the worker knows about that were not
* part of the query, sent as one or more paginated OPCode_jobStateReport
* messages.
*
* White-box #include of both ../src/xcecd_worker_utils.c (task 4.1's
* retention + running-list lookups that xcecd_resync.c's classify logic
* calls into for real, not against reimplemented stand-ins) and
* ../src/xcecd_resync.c (this task's own production code under test) --
* same convention as test_finished_job_retention.c and
* test_controller_connector.c. Link-satisfying stubs for
* xcecd_worker_utils.c's OTHER external dependencies are copied verbatim
* from test_finished_job_retention.c's own header comment/stub set; none
* of them are exercised by the assertions below.
*
* Wire-level assertions (paginated OPCode_jobStateReport sends) use
* test_job_state_wire.c's own no-live-socket technique: a real (heap)
* endpoint_t via endpoint_get() with ep->iomgr == NULL, so
* xce_job_state_report_req() can be exercised end-to-end -- real
* xce_encode() on send, real xce_decode() on "receive" -- without a
* socket. Each queued page is popped with endpoint_deq()/ITPTR_TO_PACKET()
* and decoded with xce_decode(), which takes ownership of (frees) the
* packet.
*/
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unity/unity.h>
#include "../src/xcecd_resync.c"
#include "../src/xcecd_worker_utils.c"
/* ════════════════════════════════════════════════════════════════════════
* Link-satisfying stubs -- none exercised by the assertions below; they
* only need to let xcecd_worker_utils.c's other, unrelated code paths
* link and (where reached at all) run to completion without touching
* real sqlite3/dbus/IPC. Copied from test_finished_job_retention.c.
* ════════════════════════════════════════════════════════════════════════ */
bool xcecd_reply_startjob(xcecd_job_t* job)
{
(void)job;
return true;
}
int xcecd_job_stopped(uint64_t job_id)
{
(void)job_id;
return 0;
}
int xcecd_job_resumed(uint64_t job_id)
{
(void)job_id;
return 0;
}
int xcecd_job_finished(xcecd_job_t* job)
{
(void)job;
return 0;
}
bool xcecd_db_update_worker(sqlite3* db, int worker_pid, int worker_status,
int job_status)
{
(void)db;
(void)worker_pid;
(void)worker_status;
(void)job_status;
return true;
}
bool xcecd_db_delete_worker(sqlite3* db, int worker_pid)
{
(void)db;
(void)worker_pid;
return true;
}
bool dbus_worker_clean(xcecd_worker_t* worker, xcecd_job_t* job)
{
(void)worker;
(void)job;
return true;
}
bool ipc_master_check_connection() { return true; }
void block_signal(int sig) { (void)sig; }
void unblock_signal(int sig) { (void)sig; }
/* ════════════════════════════════════════════════════════════════════════
* Fixtures
* ════════════════════════════════════════════════════════════════════════ */
static xcecd_worker_t* make_worker(int pid, uint64_t jobid, int worker_status)
{
xcecd_worker_t* w = (xcecd_worker_t*)calloc(1, sizeof(*w));
w->worker_pid = pid;
w->worker_status = worker_status;
w->job_status
= (worker_status == WORKER_RUNNING) ? JOB_RUNNING : JOB_EXITED;
w->job_info.jobid = jobid;
return w;
}
static void free_worker_list(xcecd_worker_t* list)
{
xcecd_worker_t* w = list;
while (w) {
xcecd_worker_t* next = w->next;
free(w);
w = next;
}
}
static void link_worker(xcecd_worker_t** list, xcecd_worker_t* w)
{
if (*list == NULL) {
*list = w;
return;
}
xcecd_worker_t* cur = *list;
while (cur->next) {
cur = cur->next;
}
cur->next = w;
w->prev = cur;
}
static endpoint_t* new_test_endpoint(void)
{
endpoint_t* ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
return ep;
}
static void 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));
}
static void assert_queue_empty(endpoint_t* ep)
{
TEST_ASSERT_NULL(endpoint_deq(ep));
}
void setUp(void) { xcecd_finished_job_retention_reset(); }
void tearDown(void) { xcecd_finished_job_retention_reset(); }
/* ════════════════════════════════════════════════════════════════════════
* xcecd_resync_classify_job: per-ID classification (Requirements 3.1, 3.3)
* ════════════════════════════════════════════════════════════════════════ */
static void test_classify_running_job(void)
{
xcecd_worker_t* w = make_worker(1, 111, WORKER_RUNNING);
xce_job_state_entry_t out;
xcecd_resync_classify_job(w, 111, 5000, &out);
TEST_ASSERT_EQUAL_UINT64(111, out.jobid);
TEST_ASSERT_EQUAL_INT(JobStateStatus_running, out.status);
free(w);
}
static void test_classify_finished_ok_job(void)
{
xcecd_retain_finished_job(222, WORKER_DONE, 0, 90000);
xce_job_state_entry_t out;
xcecd_resync_classify_job(NULL, 222, 95000, &out);
TEST_ASSERT_EQUAL_UINT64(222, out.jobid);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedOk, out.status);
TEST_ASSERT_EQUAL_UINT64(90000, out.finish_time_ms);
}
static void test_classify_finished_fail_job(void)
{
xcecd_retain_finished_job(333, WORKER_EXITED, 137, 90500);
xce_job_state_entry_t out;
xcecd_resync_classify_job(NULL, 333, 95000, &out);
TEST_ASSERT_EQUAL_UINT64(333, out.jobid);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedFail, out.status);
TEST_ASSERT_EQUAL_INT32(137, out.exit_code);
TEST_ASSERT_EQUAL_UINT64(90500, out.finish_time_ms);
}
static void test_classify_unrecognized_job(void)
{
xce_job_state_entry_t out;
xcecd_resync_classify_job(NULL, 444, 95000, &out);
TEST_ASSERT_EQUAL_UINT64(444, out.jobid);
TEST_ASSERT_EQUAL_INT(JobStateStatus_unrecognized, out.status);
}
/* A job present in the worker list but already terminal (worker_status
* WORKER_DONE/EXITED, lingering before cleanup_worker_list() reaps it)
* must NOT be reported as running -- it must fall through to retention
* (populated by the same update_worker_status() hook at the same
* transition, task 4.1), matching design.md's "check the running list
* first; if not running, check retention" order exactly. */
static void test_classify_terminal_worker_entry_falls_through_to_retention(void)
{
xcecd_worker_t* w = make_worker(5, 555, WORKER_DONE);
xcecd_retain_finished_job(555, WORKER_DONE, 0, 80000);
xce_job_state_entry_t out;
xcecd_resync_classify_job(w, 555, 85000, &out);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedOk, out.status);
free(w);
}
/* Requirement 3.3: a job whose process state is inconclusive (here,
* WORKER_UNKNOWN -- the one worker_status update_worker_status() never
* feeds into retention) must be reported unrecognized, not guessed. */
static void test_classify_worker_unknown_status_is_unrecognized(void)
{
xcecd_worker_t* w = make_worker(6, 666, WORKER_UNKNOWN);
xce_job_state_entry_t out;
xcecd_resync_classify_job(w, 666, 85000, &out);
TEST_ASSERT_EQUAL_INT(JobStateStatus_unrecognized, out.status);
free(w);
}
/* Expiry boundary: a retained job older than the configured window must
* classify as unrecognized, not finished (task 4.1's own boundary
* semantics, consumed here through the classify layer). */
static void test_classify_expired_retention_is_unrecognized(void)
{
xcecd_finished_job_retention_set_window_ms(1000);
xcecd_retain_finished_job(777, WORKER_DONE, 0, 100000);
xce_job_state_entry_t out;
xcecd_resync_classify_job(NULL, 777, 100000 + 1000 + 1, &out);
TEST_ASSERT_EQUAL_INT(JobStateStatus_unrecognized, out.status);
}
/* ════════════════════════════════════════════════════════════════════════
* xcecd_resync_build_report: query answers + extras (Requirements 3.2, 3.6)
* ════════════════════════════════════════════════════════════════════════ */
static xce_job_state_entry_t* find_entry(xce_job_state_entry_t** entries, int n,
uint64_t jobid)
{
int i;
for (i = 0; i < n; i++) {
if (entries[i]->jobid == jobid) {
return entries[i];
}
}
return NULL;
}
/* Every queried ID gets exactly one entry, none silently skipped, mixing
* all four outcome kinds (Requirement 3.2, and the task's own Observable
* Completion: "a query covering a mix of running, finished, and unknown
* job IDs produces one correctly-classified entry per ID"). */
static void test_build_report_answers_every_queried_id_exactly_once(void)
{
xcecd_worker_t* list = NULL;
link_worker(&list, make_worker(1, 1001, WORKER_RUNNING));
xcecd_retain_finished_job(1002, WORKER_DONE, 0, 50000);
xcecd_retain_finished_job(1003, WORKER_EXITED, 9, 50500);
/* 1004 is queried but the worker has no record of it at all. */
uint64_t jobids[4] = { 1001, 1002, 1003, 1004 };
xce_job_state_entry_t** entries = NULL;
int n = 0;
int rc = xcecd_resync_build_report(list, jobids, 4, 60000, &entries, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(4, n);
xce_job_state_entry_t* e1001 = find_entry(entries, n, 1001);
xce_job_state_entry_t* e1002 = find_entry(entries, n, 1002);
xce_job_state_entry_t* e1003 = find_entry(entries, n, 1003);
xce_job_state_entry_t* e1004 = find_entry(entries, n, 1004);
TEST_ASSERT_NOT_NULL(e1001);
TEST_ASSERT_EQUAL_INT(JobStateStatus_running, e1001->status);
TEST_ASSERT_EQUAL_INT(1, e1001->queried);
TEST_ASSERT_NOT_NULL(e1002);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedOk, e1002->status);
TEST_ASSERT_EQUAL_UINT64(50000, e1002->finish_time_ms);
TEST_ASSERT_EQUAL_INT(1, e1002->queried);
TEST_ASSERT_NOT_NULL(e1003);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedFail, e1003->status);
TEST_ASSERT_EQUAL_INT32(9, e1003->exit_code);
TEST_ASSERT_EQUAL_INT(1, e1003->queried);
TEST_ASSERT_NOT_NULL(e1004);
TEST_ASSERT_EQUAL_INT(JobStateStatus_unrecognized, e1004->status);
TEST_ASSERT_EQUAL_INT(1, e1004->queried);
xcecd_resync_free_entries(entries, n);
free_worker_list(list);
}
/* Requirement 3.6: a job the worker is running that was NOT part of the
* query is reported as an extra (queried=0). */
static void test_build_report_includes_running_extra(void)
{
xcecd_worker_t* list = NULL;
link_worker(&list, make_worker(1, 2001, WORKER_RUNNING));
link_worker(&list, make_worker(2, 2002, WORKER_RUNNING)); /* extra */
uint64_t jobids[1] = { 2001 };
xce_job_state_entry_t** entries = NULL;
int n = 0;
int rc = xcecd_resync_build_report(list, jobids, 1, 1000, &entries, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, n);
xce_job_state_entry_t* queried = find_entry(entries, n, 2001);
xce_job_state_entry_t* extra = find_entry(entries, n, 2002);
TEST_ASSERT_NOT_NULL(queried);
TEST_ASSERT_EQUAL_INT(1, queried->queried);
TEST_ASSERT_NOT_NULL(extra);
TEST_ASSERT_EQUAL_INT(0, extra->queried);
TEST_ASSERT_EQUAL_INT(JobStateStatus_running, extra->status);
xcecd_resync_free_entries(entries, n);
free_worker_list(list);
}
/* Requirement 3.6: a job the worker recently finished that was NOT part
* of the query is reported as an extra, with its real finish time. */
static void test_build_report_includes_finished_extra(void)
{
xcecd_retain_finished_job(3001, WORKER_EXITED, 5, 70000);
xce_job_state_entry_t** entries = NULL;
int n = 0;
int rc = xcecd_resync_build_report(NULL, NULL, 0, 75000, &entries, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, n);
TEST_ASSERT_EQUAL_UINT64(3001, entries[0]->jobid);
TEST_ASSERT_EQUAL_INT(JobStateStatus_finishedFail, entries[0]->status);
TEST_ASSERT_EQUAL_INT32(5, entries[0]->exit_code);
TEST_ASSERT_EQUAL_UINT64(70000, entries[0]->finish_time_ms);
TEST_ASSERT_EQUAL_INT(0, entries[0]->queried);
xcecd_resync_free_entries(entries, n);
}
/* A job that IS part of the query must never also appear a second time
* as an extra -- the queried answer is the only entry for it. */
static void test_build_report_queried_job_not_duplicated_as_extra(void)
{
xcecd_worker_t* list = NULL;
link_worker(&list, make_worker(1, 4001, WORKER_RUNNING));
xcecd_retain_finished_job(4002, WORKER_DONE, 0, 10000);
uint64_t jobids[2] = { 4001, 4002 };
xce_job_state_entry_t** entries = NULL;
int n = 0;
int rc = xcecd_resync_build_report(list, jobids, 2, 20000, &entries, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, n);
int count4001 = 0, count4002 = 0;
int i;
for (i = 0; i < n; i++) {
if (entries[i]->jobid == 4001)
count4001++;
if (entries[i]->jobid == 4002)
count4002++;
}
TEST_ASSERT_EQUAL_INT(1, count4001);
TEST_ASSERT_EQUAL_INT(1, count4002);
xcecd_resync_free_entries(entries, n);
free_worker_list(list);
}
/* Empty query, nothing running or retained -> a valid, empty (zero-entry)
* result, not an error. */
static void test_build_report_empty_query_no_state_yields_empty_result(void)
{
xce_job_state_entry_t** entries
= (xce_job_state_entry_t**)0x1; /* sentinel */
int n = -1;
int rc = xcecd_resync_build_report(NULL, NULL, 0, 1000, &entries, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, n);
TEST_ASSERT_NULL(entries);
}
static void test_build_report_null_out_params_rejected(void)
{
uint64_t jobids[1] = { 1 };
xce_job_state_entry_t** entries = NULL;
int n = 0;
TEST_ASSERT_EQUAL_INT(
-EINVAL, xcecd_resync_build_report(NULL, jobids, 1, 1000, NULL, &n));
TEST_ASSERT_EQUAL_INT(-EINVAL, xcecd_resync_build_report(
NULL, jobids, 1, 1000, &entries, NULL));
}
/* ════════════════════════════════════════════════════════════════════════
* xcecd_resync_answer_query: end-to-end paginated send (Requirements 8.2,
* 8.3)
* ════════════════════════════════════════════════════════════════════════ */
static void test_answer_query_single_page(void)
{
xcecd_worker_t* list = NULL;
link_worker(&list, make_worker(1, 5001, WORKER_RUNNING));
xcecd_retain_finished_job(5002, WORKER_DONE, 0, 30000);
uint64_t jobids[2] = { 5001, 5002 };
char nm[] = "worker1";
char ky[] = "key";
xce_peerinfo_t from = { .name = nm, .key = ky };
endpoint_t* ep = new_test_endpoint();
int rc = xcecd_resync_answer_query(list, ep, &from, jobids, 2, 40000, 0);
TEST_ASSERT_EQUAL_INT(0, rc);
xce_request_t decoded;
pop_and_decode(ep, &decoded);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_jobstatereportmsg, decoded.body_which);
TEST_ASSERT_EQUAL_INT(2, decoded.body.jobstatereportmsg->n_total);
TEST_ASSERT_EQUAL_INT(2, decoded.body.jobstatereportmsg->count);
xce_free_request(&decoded);
assert_queue_empty(ep);
endpoint_fini(ep);
free(ep);
free_worker_list(list);
}
/* Requirement 8.3: nothing truncated/dropped across multiple pages, for
* a mix of queried answers and extras. */
static void test_answer_query_multipage_covers_every_job(void)
{
xcecd_worker_t* list = NULL;
int i;
/* 3 queried running jobs. */
uint64_t jobids[3] = { 6001, 6002, 6003 };
for (i = 0; i < 3; i++) {
link_worker(&list, make_worker(100 + i, jobids[i], WORKER_RUNNING));
}
/* 2 extra running jobs, not queried. */
link_worker(&list, make_worker(200, 6004, WORKER_RUNNING));
link_worker(&list, make_worker(201, 6005, WORKER_RUNNING));
/* 2 extra finished jobs, not queried. */
xcecd_retain_finished_job(6006, WORKER_DONE, 0, 90000);
xcecd_retain_finished_job(6007, WORKER_EXITED, 3, 90500);
char nm[] = "worker1";
char ky[] = "key";
xce_peerinfo_t from = { .name = nm, .key = ky };
endpoint_t* ep = new_test_endpoint();
/* 7 total entries, pktlimit=3 -> pages of 3, 3, 1. */
int rc = xcecd_resync_answer_query(list, ep, &from, jobids, 3, 95000, 3);
TEST_ASSERT_EQUAL_INT(0, rc);
uint64_t rebuilt[7];
int queried_flags[7];
int rebuilt_n = 0;
int page;
for (page = 0; page < 3; page++) {
xce_request_t decoded;
pop_and_decode(ep, &decoded);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded.opcode);
TEST_ASSERT_EQUAL_INT(7, decoded.body.jobstatereportmsg->n_total);
int c = decoded.body.jobstatereportmsg->count;
int i2;
for (i2 = 0; i2 < c; i2++) {
xce_job_state_entry_t* got
= decoded.body.jobstatereportmsg->entries[i2];
rebuilt[rebuilt_n] = got->jobid;
queried_flags[rebuilt_n] = got->queried;
rebuilt_n++;
}
xce_free_request(&decoded);
}
assert_queue_empty(ep);
TEST_ASSERT_EQUAL_INT(7, rebuilt_n);
/* Every job from 6001..6007 must appear exactly once across all
* pages, with the correct queried flag. */
uint64_t expected[7] = { 6001, 6002, 6003, 6004, 6005, 6006, 6007 };
int expected_q[7] = { 1, 1, 1, 0, 0, 0, 0 };
int j;
for (j = 0; j < 7; j++) {
int found = 0;
int k;
for (k = 0; k < 7; k++) {
if (rebuilt[k] == expected[j]) {
TEST_ASSERT_EQUAL_INT(expected_q[j], queried_flags[k]);
found = 1;
}
}
TEST_ASSERT_TRUE_MESSAGE(found, "every job must appear exactly once "
"across all report pages, none "
"dropped");
}
endpoint_fini(ep);
free(ep);
free_worker_list(list);
}
/* An empty query against a worker with no running/retained jobs still
* sends exactly one (empty) report page -- not skipped. */
static void test_answer_query_empty_still_sends_one_page(void)
{
uint64_t* jobids = NULL;
char nm[] = "worker1";
char ky[] = "key";
xce_peerinfo_t from = { .name = nm, .key = ky };
endpoint_t* ep = new_test_endpoint();
int rc = xcecd_resync_answer_query(NULL, ep, &from, jobids, 0, 1000, 0);
TEST_ASSERT_EQUAL_INT(0, rc);
xce_request_t decoded;
pop_and_decode(ep, &decoded);
TEST_ASSERT_EQUAL_INT(OPCode_jobStateReport, decoded.opcode);
TEST_ASSERT_EQUAL_INT(0, decoded.body.jobstatereportmsg->n_total);
TEST_ASSERT_EQUAL_INT(0, decoded.body.jobstatereportmsg->count);
xce_free_request(&decoded);
assert_queue_empty(ep);
endpoint_fini(ep);
free(ep);
}
/* ════════════════════════════════════════════════════════════════════════
* xcecd_finished_job_retention_snapshot: task 4.2's companion addition to
* xcecd_worker_utils.c/h (needed to enumerate retention for extras, since
* task 4.1's own public API was jobid-keyed lookup only).
* ════════════════════════════════════════════════════════════════════════ */
static void test_retention_snapshot_returns_all_unexpired_entries(void)
{
xcecd_retain_finished_job(9001, WORKER_DONE, 0, 10000);
xcecd_retain_finished_job(9002, WORKER_EXITED, 1, 10500);
xcecd_finished_job_t* snap = NULL;
int n = 0;
int rc = xcecd_finished_job_retention_snapshot(20000, &snap, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, n);
TEST_ASSERT_NOT_NULL(snap);
free(snap);
}
static void test_retention_snapshot_excludes_expired_entries(void)
{
xcecd_finished_job_retention_set_window_ms(1000);
xcecd_retain_finished_job(9101, WORKER_DONE, 0, 10000);
xcecd_finished_job_t* snap = NULL;
int n = 0;
int rc = xcecd_finished_job_retention_snapshot(10000 + 1000 + 1, &snap, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, n);
TEST_ASSERT_NULL(snap);
}
static void test_retention_snapshot_empty_store(void)
{
xcecd_finished_job_t* snap = (xcecd_finished_job_t*)0x1;
int n = -1;
int rc = xcecd_finished_job_retention_snapshot(1000, &snap, &n);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, n);
TEST_ASSERT_NULL(snap);
}
static void test_retention_snapshot_null_out_params_rejected(void)
{
xcecd_finished_job_t* snap = NULL;
int n = 0;
TEST_ASSERT_EQUAL_INT(
-EINVAL, xcecd_finished_job_retention_snapshot(1000, NULL, &n));
TEST_ASSERT_EQUAL_INT(
-EINVAL, xcecd_finished_job_retention_snapshot(1000, &snap, NULL));
}
/* ════════════════════════════════════════════════════════════════════════
* main
* ════════════════════════════════════════════════════════════════════════ */
int main(void)
{
xce_init_xcecd_log("/tmp/test_xcecd_resync.log", 1024 * 1024, 2);
UNITY_BEGIN();
RUN_TEST(test_classify_running_job);
RUN_TEST(test_classify_finished_ok_job);
RUN_TEST(test_classify_finished_fail_job);
RUN_TEST(test_classify_unrecognized_job);
RUN_TEST(test_classify_terminal_worker_entry_falls_through_to_retention);
RUN_TEST(test_classify_worker_unknown_status_is_unrecognized);
RUN_TEST(test_classify_expired_retention_is_unrecognized);
RUN_TEST(test_build_report_answers_every_queried_id_exactly_once);
RUN_TEST(test_build_report_includes_running_extra);
RUN_TEST(test_build_report_includes_finished_extra);
RUN_TEST(test_build_report_queried_job_not_duplicated_as_extra);
RUN_TEST(test_build_report_empty_query_no_state_yields_empty_result);
RUN_TEST(test_build_report_null_out_params_rejected);
RUN_TEST(test_answer_query_single_page);
RUN_TEST(test_answer_query_multipage_covers_every_job);
RUN_TEST(test_answer_query_empty_still_sends_one_page);
RUN_TEST(test_retention_snapshot_returns_all_unexpired_entries);
RUN_TEST(test_retention_snapshot_excludes_expired_entries);
RUN_TEST(test_retention_snapshot_empty_store);
RUN_TEST(test_retention_snapshot_null_out_params_rejected);
return UNITY_END();
}