diff --git a/.kiro/specs/config-upgrade-snapshot/tasks.md b/.kiro/specs/config-upgrade-snapshot/tasks.md index 10a2f28d..ad75fced 100644 --- a/.kiro/specs/config-upgrade-snapshot/tasks.md +++ b/.kiro/specs/config-upgrade-snapshot/tasks.md @@ -7,7 +7,7 @@ - _Requirements: 1.9, 5.7_ - [ ] 2. Core: snapshot entity and configuration payload — collection and write side -- [ ] 2.1 Collect host admin-state frames into the checkpoint +- [x] 2.1 Collect host admin-state frames into the checkpoint - Walk the live host table the same way existing job/queue frame collection does, encoding only administratively-set status bits (open/closed/locked/tagged) per host as a HostAdminState frame - Exclude every worker-reported, dynamically-changing field (resource values, liveness timestamps, non-admin status bits) from the encoded frame - Observable: a checkpoint taken while a host is administratively closed contains exactly one HostAdminState frame for that host with only the admin bits set, and none of its dynamic fields @@ -149,6 +149,8 @@ ## Implementation Notes +- **Task 2.1 → task 3 heads-up:** host-frame collection is unconditional (every host in `c->workermgr` gets a frame, status masked to admin bits, may be 0) — confirmed correct against requirements.md/design.md by both implementer and reviewer. Consequence for task 3 (`restore_host_state`): a checkpoint may contain many zero-admin-status host frames on large clusters. Not required to special-case this, but consider whether `restore_host_state` should skip placeholder creation when `admin_status == 0` on a miss, purely as a cost optimization — current design.md contract doesn't require it either way. + - **Build environment (bug-918 recurrence 4, fixed in this worktree):** `nix develop` was hard-broken — `xbuild`'s `capnpc` package pins its `src` to the MUTABLE `main` ref of `shen390s/c-capnproto` with a hardcoded sha256, so it hash-mismatches every time that branch moves upstream (recurred 3x before in this same worktree per `.wolf/buglog.json`). Fixed durably by overriding `capnpc`'s `src` in `src/nixSupport/xce.nix` to a `pkgs.fetchFromGitHub` pinned at an immutable commit (`rev`+`sha256`) instead of tracking `main`. Any future task in this spec that needs a fresh `nix develop` should not hit this again; if it does, the fix pattern is the same (re-pin to the then-current upstream commit). - **Test-runner gotcha:** running `controller_unit_test` with default ASan settings hangs indefinitely in this sandbox — LeakSanitizer's stop-the-world thread scan never completes (confirmed: 50+ min wall clock, ~5s CPU, `futex_do_wait`). Always run with `ASAN_OPTIONS=detect_leaks=0` (matches the project's own meson test registration). Do not wrap the binary in `stdbuf` or anything else that adds its own `LD_PRELOAD` — conflicts with ASan's preload requirement and the binary refuses to start. - **After any `SNAPSHOT_FORMAT_VERSION` bump:** grep the whole `testsuite/` tree for other hardcoded copies of the old format-version literal before considering the bump done — `test_persist_snapshot_entities.c` (a different, already-shipped spec's test file) had its own independent `SNAPENT_TEST_FORMAT_VERSION` constant that silently went stale after task 1's bump and broke 3 unrelated tests until caught. diff --git a/src/controller/persist.c b/src/controller/persist.c index d1d0ac5a..9b8f4192 100644 --- a/src/controller/persist.c +++ b/src/controller/persist.c @@ -101,6 +101,34 @@ static int capnp_encode_worker(const xce_worker_t* w, uint8_t** out, size_t* out return 0; } +/* Serialize a host admin-state frame (config-upgrade-snapshot task 2.1, Req + * 1.6/1.7/1.9) to a malloc'd Cap'n Proto buffer. Mirrors capnp_encode_job + * above exactly -- capnp_host_admin_state_t (typemap.h) is a frame-only + * (name, status) pair, never the full xce_worker_t (design.md Design + * Invariant 5). Caller must free(*out) on success. */ +static int capnp_encode_host_admin_state(const capnp_host_admin_state_t* h, + uint8_t** out, size_t* out_len) +{ + struct capn c; + struct capn_segment* cs; + struct HostAdminState ch; + HostAdminState_ptr hp; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_HostAdminState(cs, &ch, (capnp_host_admin_state_t*)h); + hp = new_HostAdminState(cs); + write_HostAdminState(&ch, hp); + capn_setp(capn_root(&c), 0, hp.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; +} + /* WAL_WORKER_CTRL payload: [uint32_t detail][capnp bytes...] or [uint32_t detail][name\0] */ #define WORKER_WAL_HDR_SIZE sizeof(uint32_t) @@ -1869,6 +1897,139 @@ static int collect_queue_frames(persist_t* p, xlist_t* frames) return rc; } +/* Bits set by admin commands that must survive a checkpoint round-trip. + * Mirrors worker.c's and lib/wobj.c's own local XCE_WORKER_ADMIN_MASK + * definitions -- both already independently duplicate this exact bit list + * (design.md Design Invariant 6 notes this pre-existing duplication); this + * is a third, deliberate copy for the same reason (no shared header exists + * for it today), not a new layering dependency on worker.c. */ +#define PERSIST_WORKER_ADMIN_MASK \ + (XCE_WORKER_LOCKEDU | XCE_WORKER_CLOSED | XCE_WORKER_HIDDEN \ + | XCE_WORKER_CLOSED_BY_ADMIN) + +typedef struct { + uint8_t* buf; + uint32_t len; +} snap_host_frame_t; + +static void snap_free_host_frames(xlist_t* frames) +{ + for (int i = 0; i < frames->count; i++) { + snap_host_frame_t* fr = (snap_host_frame_t*)xlist_get(frames, i); + if (fr) { + free(fr->buf); + free(fr); + } + } + xlist_fini(frames); +} + +/* Collect + encode every host in c->workermgr into `frames`, one malloc'd + * snap_host_frame_t (identical {buf,len} shape to snap_job_frame_t -- both + * carry a capnp payload) per host, under the same point-in-time-consistent + * walk discipline as + * collect_job_frames/collect_queue_frames above (design.md "Existing + * Architecture Analysis" / Requirement 1.2): the workermgr table's own + * RTTI_LOCK_READ is held for the ENTIRE walk, and each worker's own + * RTTI_LOCK_READ is ADDITIONALLY taken nested around just that host's + * (name, status) read and encode. No lock is held during the disk write + * itself. + * + * Like collect_queue_frames and UNLIKE collect_job_frames, no retention + * predicate is applied: every host currently in the live workermgr table + * gets exactly one frame (mirrors collect_queue_frames's own "mere presence + * already means it's included" precedent above), so restore later sees a + * frame for every host this member currently knows about, whether or not + * any admin bit happens to be set on it right now. + * + * Each frame carries ONLY the host's admin-issued status bits + * (w->host.status & PERSIST_WORKER_ADMIN_MASK) -- never the raw status + * (which also carries dynamic/liveness bits like XCE_WORKER_UNAVAIL) and + * never any other xce_worker_t field (attrs, resvals, dead_since, ...): + * Requirement 1.7's exclusion, design.md Design Invariant 5 (never inherit + * a live struct's dynamic fields just because reusing it is convenient -- + * capnp_encode_worker/Worker is deliberately NOT used here). `name` is + * read directly off w->host.name -- the same field key_find(c->workermgr, + * name) looks up elsewhere in this codebase (join.c:91, worker.c:524,645,751, + * lib/wobj.c:212), so a later restore's key_find(name) lookup matches + * exactly what was collected here. + * + * Returns 0 on success (frames populated; `frames` is always left + * xlist_init'd -- even zero results -- so the caller can always + * xlist_fini it) or a negative errno on the first encode failure, in which + * case every frame collected so far has already been freed and `frames` + * is empty (Requirement 1.3: a mid-walk failure must not leave partial + * entity content behind). */ +static int collect_host_frames(persist_t* p, xlist_t* frames) +{ + cluster_t* c = p->cluster; + int rc = 0; + + xlist_init(frames, 64); + + /* No live cluster back-pointer (e.g. a bare unit-test persist_t) -- + * zero hosts is not a failure. */ + if (c == NULL || c->workermgr == NULL) + return 0; + + lock(c->workermgr, RTTI_LOCK_READ); + + iterator_t it; + int irc = iter_make(&it, c->workermgr); + if (irc < 0) { + unlock(c->workermgr); + return 0; /* nothing to walk -- mirrors collect_job/queue_frames' empty-table handling */ + } + + for (;;) { + worker_t* w = NULL; + int nrc = iter_next(&it, (void**)&w); + if (nrc < 0 || nrc == RTTI_ITER_NO_DATA) + break; + if (w == NULL) + continue; + + capnp_host_admin_state_t h; + uint8_t* buf = NULL; + size_t sz = 0; + int enc_rc; + + lock(w, RTTI_LOCK_READ); + h.name = w->host.name; + h.status = w->host.status & PERSIST_WORKER_ADMIN_MASK; + enc_rc = capnp_encode_host_admin_state(&h, &buf, &sz); + unlock(w); + + if (enc_rc != 0) { + rc = enc_rc; + break; + } + + snap_host_frame_t* fr = (snap_host_frame_t*)malloc(sizeof(*fr)); + if (fr == NULL) { + free(buf); + rc = -ENOMEM; + break; + } + fr->buf = buf; + fr->len = (uint32_t)sz; + if (xlist_store(frames, fr) < 0) { + free(buf); + free(fr); + rc = -ENOMEM; + break; + } + } + + iter_destroy(&it); + unlock(c->workermgr); + + if (rc != 0) + snap_free_host_frames(frames); /* Requirement 1.3: discard, not partial */ + + return rc; +} + /* Write an entity-bearing checkpoint: legacy header (unchanged) + entity * payload (ha-entity-snapshots task 2.2/3.2, Req 1.1-1.4, 3.1-3.3). Any * failure at any step -- collect, encode, write, sync -- leaves NO file at @@ -1899,10 +2060,19 @@ static int write_snapshot(persist_t* p) return rc; } + xlist_t host_frames; + rc = collect_host_frames(p, &host_frames); + if (rc != 0) { + snap_free_job_frames(&job_frames); + snap_free_queue_frames(&queue_frames); + return rc; + } + int fd = open(tmp_path, O_CREAT | O_WRONLY | O_TRUNC, 0600); if (fd < 0) { snap_free_job_frames(&job_frames); snap_free_queue_frames(&queue_frames); + snap_free_host_frames(&host_frames); return -errno; } @@ -1976,12 +2146,35 @@ static int write_snapshot(persist_t* p) } } + /* Host trailer (config-upgrade-snapshot task 2.1, Req 1.6, 1.7, 1.9): + * capnp HostAdminState frames, same [payload_len][capnp bytes] shape as + * the job frame loop above (design.md "Checkpoint File Layout": host + * frames come after queue frames). */ + if (rc == 0) { + uint32_t host_count = (uint32_t)host_frames.count; + if (write(fd, &host_count, sizeof(host_count)) != (ssize_t)sizeof(host_count)) + rc = -EIO; + } + + for (int i = 0; rc == 0 && i < host_frames.count; i++) { + snap_host_frame_t* fr = (snap_host_frame_t*)xlist_get(&host_frames, i); + if (write(fd, &fr->len, sizeof(fr->len)) != (ssize_t)sizeof(fr->len)) { + rc = -EIO; + break; + } + if (write(fd, fr->buf, fr->len) != (ssize_t)fr->len) { + rc = -EIO; + break; + } + } + if (rc == 0 && fdatasync(fd) != 0) rc = -errno; close(fd); snap_free_job_frames(&job_frames); snap_free_queue_frames(&queue_frames); + snap_free_host_frames(&host_frames); if (rc != 0) { unlink(tmp_path); diff --git a/src/controller/testsuite/test_persist_snapshot_entities.c b/src/controller/testsuite/test_persist_snapshot_entities.c index dfc5c91b..aa5fbe04 100644 --- a/src/controller/testsuite/test_persist_snapshot_entities.c +++ b/src/controller/testsuite/test_persist_snapshot_entities.c @@ -69,6 +69,7 @@ #include "controller.capnp.h" #include "jobj.h" #include "qobj.h" +#include "wobj.h" #include "xce/cluster_def.h" #include "xce/types.h" #include "xstring.h" @@ -113,6 +114,39 @@ static uint32_t snapent_queue_status(int i) return g_snapent_queue_statuses[i % (int)SNAPENT_N_QUEUE_STATUSES]; } +/* Host admin-status seed values (task 2.1, Req 1.6/1.7). Deliberately + * includes a ZERO entry (a host with no admin action ever taken -- the + * "collect every host, some just have status=0" case: collect_host_frames + * applies no retention predicate, mirroring collect_queue_frames) and a + * multi-bit combination, alongside single-bit CLOSED_BY_ADMIN/LOCKEDU + * cases. */ +static const uint32_t g_snapent_host_admin_statuses[] = { + 0u, + XCE_WORKER_CLOSED_BY_ADMIN, + XCE_WORKER_LOCKEDU, + (XCE_WORKER_CLOSED_BY_ADMIN | XCE_WORKER_HIDDEN), +}; +#define SNAPENT_N_HOST_ADMIN_STATUSES \ + (sizeof(g_snapent_host_admin_statuses) / sizeof(g_snapent_host_admin_statuses[0])) + +/* Dynamic/non-admin bits ORed onto every seeded host's REAL status + * alongside its admin bits (Requirement 1.7): a seeded host's frame must + * exclude these even though they are set on the live worker_t, proving + * collect_host_frames actually masks with PERSIST_WORKER_ADMIN_MASK rather + * than merely happening to equal the admin bits because nothing else was + * ever set. */ +#define SNAPENT_HOST_DYNAMIC_BITS (XCE_WORKER_UNAVAIL | XCE_WORKER_BUSY) + +static void snapent_host_name(int i, char* buf, size_t buf_sz) +{ + snprintf(buf, buf_sz, "snapent-host%d", i); +} + +static uint32_t snapent_host_admin_status(int i) +{ + return g_snapent_host_admin_statuses[i % (int)SNAPENT_N_HOST_ADMIN_STATUSES]; +} + /* Deterministic, distinguishing submit-payload values per seeded job index * (task 2.1 / Implementation Notes: richer than an all-zeroed submit, so a * round-trip's field-by-field comparison is meaningful). Only the fields the @@ -210,8 +244,40 @@ static void snapent_seed_queues(cluster_t* c, int m) } } +/* Seeds n hosts directly into c->workermgr via makeWorker -- the same + * makeWorker()-without-join precedent create_lost_and_found (config_repl.c) + * uses, standing in for a real join the way seeded jobs/queues stand in for + * their own real insertion paths. Each host's REAL status carries its + * admin bits (snapent_host_admin_status) PLUS dynamic/liveness bits + * (SNAPENT_HOST_DYNAMIC_BITS) so a round-trip test can prove the collected + * frame excludes the latter (Requirement 1.7), not merely happens to match + * because nothing else was set. */ +static void snapent_seed_hosts(cluster_t* c, int n) +{ + for (int i = 0; i < n; i++) { + char name[32]; + snapent_host_name(i, name, sizeof(name)); + + hostattrs_t attrs; + memset(&attrs, 0, sizeof(attrs)); + + xce_worker_t hdef; + memset(&hdef, 0, sizeof(hdef)); + hdef.name = name; + hdef.attrs = &attrs; /* makeWorker's copy_worker requires non-NULL attrs */ + hdef.status = snapent_host_admin_status(i) | SNAPENT_HOST_DYNAMIC_BITS; + + TEST_ASSERT_NOT_NULL_MESSAGE(makeWorker(c->workermgr, &hdef), + "seed host insertion must succeed"); + } +} + /* make an entity-snapshot fixture: a real persist_t (private tmpdir) embedded * in a minimal cluster_t whose jobmgr/qmgr are seeded with n_jobs/m_queues. + * workermgr is always created (empty) too -- tests that need hosts call + * snapent_seed_hosts explicitly afterward (task 2.1), mirroring how + * n_jobs/m_queues seed at construction time but keeping every existing + * 2-argument call site unchanged. * persist_init registers the cluster back-pointer (p->cluster) so future * write_snapshot/load_snapshot entity collection can walk c->jobmgr/c->qmgr — * the same access path collect_jobs/collect_queues (dataex.c) use. */ @@ -223,6 +289,9 @@ static snapent_fixture_t* snapent_make(int n_jobs, int m_queues) kvmgr_params_t str_params = { .key_type = KVMGR_KEY_STRING, .nslots = 64, .delta = 16 }; + kvmgr_params_t worker_str_params = { .key_type = KVMGR_KEY_STRING, + .nslots = 64, + .delta = 16 }; snapent_fixture_t* f = calloc(1, sizeof(*f)); if (f == NULL) @@ -241,10 +310,12 @@ static snapent_fixture_t* snapent_make(int n_jobs, int m_queues) TEST_ASSERT_EQUAL_INT( 0, xce_hook_init(&f->c->hooksys, XCE_CONTROLLER_HOOK_MAX)); - f->c->jobmgr = makeInstance(&xkvmgr_type, &int_params); - f->c->qmgr = makeInstance(&xkvmgr_type, &str_params); + f->c->jobmgr = makeInstance(&xkvmgr_type, &int_params); + f->c->qmgr = makeInstance(&xkvmgr_type, &str_params); + f->c->workermgr = makeInstance(&xkvmgr_type, &worker_str_params); TEST_ASSERT_NOT_NULL(f->c->jobmgr); TEST_ASSERT_NOT_NULL(f->c->qmgr); + TEST_ASSERT_NOT_NULL(f->c->workermgr); zarray_init(&f->c->sharedinst, 64, NULL); if (!g_snapent_jobobj_ready) { @@ -276,8 +347,9 @@ static void snapent_teardown(snapent_fixture_t* f) persist_fini(&f->c->persist); zarray_fini(&f->c->sharedinst); - if (f->c->qmgr) dereference(f->c->qmgr); - if (f->c->jobmgr) dereference(f->c->jobmgr); + if (f->c->qmgr) dereference(f->c->qmgr); + if (f->c->jobmgr) dereference(f->c->jobmgr); + if (f->c->workermgr) dereference(f->c->workermgr); xce_hook_fini(&f->c->hooksys); free(f->c); @@ -942,6 +1014,325 @@ static void test_snapent_checkpoint_queue_status_roundtrip(void) printf("test_snapent_checkpoint_queue_status_roundtrip: PASS\n"); } +/* ------------------------------------------------------------------ */ +/* Test-local raw checkpoint reader for the host section (task 2.1). */ +/* */ +/* This task's boundary is write_snapshot ONLY -- load_snapshot's host */ +/* restore is task 3/4's job (design.md "Host Restore"), mirroring the */ +/* job/queue readers above. Reads the checkpoint's on-disk bytes */ +/* directly per design.md's "Checkpoint File Layout": */ +/* [57-byte legacy sentinel][uint32_t format_version] */ +/* [uint32_t job_count][job_count * {payload_len; capnp Job bytes}] */ +/* [uint32_t queue_count][queue_count * {name_len; name; status}] */ +/* [uint32_t host_count][host_count * {payload_len; capnp */ +/* HostAdminState bytes}] */ +/* The queue section is still the pre-task-2.2 raw layout (this task */ +/* does not touch collect_queue_frames), so it is skipped using the */ +/* same raw shape snapent_read_checkpoint_queues above parses, not */ +/* capnp-decoded here -- only the host section, this task's actual */ +/* boundary, is decoded via read_HostAdminState/decode_HostAdminState, */ +/* the same generated helpers restore_host_state (task 3) will use. */ +/* ------------------------------------------------------------------ */ + +#define SNAPENT_MAX_HNAME_LEN 63 + +typedef struct { + char name[SNAPENT_MAX_HNAME_LEN + 1]; + uint32_t status; +} snapent_decoded_host_t; + +/* Reads every host frame in the checkpoint at `path` into `out` + * (caller-allocated array of `max_out` slots). Returns the number of + * frames decoded (<= host_count on disk, capped at max_out), or -1 on any + * structural read error (bad magic, bad/absent format_version, short + * read, oversized job/queue/host payload_len or queue name_len). */ +static int snapent_read_checkpoint_hosts(const char* path, + snapent_decoded_host_t* out, + int max_out) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) + return -1; + + char header[SNAPENT_LEGACY_HEADER_BYTES]; + if (read(fd, header, sizeof(header)) != (ssize_t)sizeof(header) || + memcmp(header, "XCESNAP\0", 8) != 0) { + close(fd); + return -1; + } + + uint32_t format_version = 0; + if (read(fd, &format_version, sizeof(format_version)) != + (ssize_t)sizeof(format_version) || + format_version != SNAPENT_TEST_FORMAT_VERSION) { + close(fd); + return -1; + } + + /* Skip the entire job section. */ + uint32_t job_count = 0; + if (read(fd, &job_count, sizeof(job_count)) != (ssize_t)sizeof(job_count)) { + close(fd); + return -1; + } + for (uint32_t i = 0; i < job_count; i++) { + uint32_t payload_len = 0; + if (read(fd, &payload_len, sizeof(payload_len)) != + (ssize_t)sizeof(payload_len)) { + close(fd); + return -1; + } + if (payload_len == 0 || payload_len > SNAPENT_MAX_DECODE_PAYLOAD) { + close(fd); + return -1; + } + uint8_t* discard = (uint8_t*)malloc(payload_len); + if (discard == NULL) { + close(fd); + return -1; + } + ssize_t got = read(fd, discard, payload_len); + free(discard); + if (got != (ssize_t)payload_len) { + close(fd); + return -1; + } + } + + /* Skip the entire queue section -- still the pre-task-2.2 raw + * {name_len, name, status} layout (unchanged by this task). */ + uint32_t queue_count = 0; + if (read(fd, &queue_count, sizeof(queue_count)) != + (ssize_t)sizeof(queue_count)) { + close(fd); + return -1; + } + for (uint32_t i = 0; i < queue_count; i++) { + uint32_t name_len = 0; + if (read(fd, &name_len, sizeof(name_len)) != (ssize_t)sizeof(name_len)) { + close(fd); + return -1; + } + if (name_len > SNAPENT_MAX_QNAME_LEN) { + close(fd); + return -1; + } + char namebuf[SNAPENT_MAX_QNAME_LEN + 1]; + if (name_len > 0 && + read(fd, namebuf, name_len) != (ssize_t)name_len) { + close(fd); + return -1; + } + uint32_t status = 0; + if (read(fd, &status, sizeof(status)) != (ssize_t)sizeof(status)) { + close(fd); + return -1; + } + } + + /* Host section (task 2.1's actual boundary): host_count + per-host + * [payload_len][capnp HostAdminState bytes]. */ + uint32_t host_count = 0; + if (read(fd, &host_count, sizeof(host_count)) != + (ssize_t)sizeof(host_count)) { + close(fd); + return -1; + } + + int n = 0; + for (uint32_t i = 0; i < host_count; i++) { + uint32_t payload_len = 0; + if (read(fd, &payload_len, sizeof(payload_len)) != + (ssize_t)sizeof(payload_len)) { + close(fd); + return -1; + } + if (payload_len == 0 || payload_len > SNAPENT_MAX_DECODE_PAYLOAD) { + close(fd); + return -1; + } + + uint8_t* buf = (uint8_t*)malloc(payload_len); + if (buf == NULL) { + close(fd); + return -1; + } + if (read(fd, buf, payload_len) != (ssize_t)payload_len) { + free(buf); + close(fd); + return -1; + } + + if (n < max_out) { + struct capn c_ctx; + capn_init_mem(&c_ctx, buf, payload_len, 0); + HostAdminState_ptr hp; + hp.p = capn_getp(capn_root(&c_ctx), 0, 1); + struct HostAdminState ch; + memset(&ch, 0, sizeof(ch)); + read_HostAdminState(&ch, hp); + + capnp_host_admin_state_t decoded; + memset(&decoded, 0, sizeof(decoded)); + decode_HostAdminState(&decoded, &ch); + capn_free(&c_ctx); + + snprintf(out[n].name, sizeof(out[n].name), "%s", + decoded.name ? decoded.name : ""); + out[n].status = decoded.status; + free_HostAdminState(&decoded); + n++; + } + free(buf); + } + + close(fd); + return n; +} + +static snapent_decoded_host_t* snapent_find_decoded_host( + snapent_decoded_host_t* hosts, int count, const char* name) +{ + for (int i = 0; i < count; i++) { + if (strcmp(hosts[i].name, name) == 0) + return &hosts[i]; + } + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* Test 6b (task 2.1): every host currently in the live workermgr table */ +/* gets exactly one HostAdminState frame, whose status is EXACTLY the */ +/* host's admin-issued bits (w->host.status & XCE_WORKER_ADMIN_MASK) -- */ +/* never missing an admin bit, never carrying a dynamic/liveness bit */ +/* (Requirements 1.6, 1.7). Covers a host with NO admin bits set too */ +/* (status must decode to exactly 0, not be omitted) -- collect_host_ */ +/* frames applies no retention predicate, mirroring collect_queue_frames.*/ +/* ------------------------------------------------------------------ */ + +static void test_snapent_checkpoint_host_admin_status_roundtrip(void) +{ + const int n_hosts = 4; /* covers every g_snapent_host_admin_statuses entry */ + + snapent_fixture_t* src = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(src); + + snapent_seed_hosts(src->c, n_hosts); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist)); + + char path[4096]; + snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp", + src->c->persist.snap_path, src->c->persist.checkpoint_lsn); + + snapent_decoded_host_t decoded[8]; + int count = snapent_read_checkpoint_hosts(path, decoded, 8); + TEST_ASSERT_TRUE_MESSAGE(count >= 0, + "checkpoint file must parse as a well-formed entity-bearing " + "payload through the host section"); + TEST_ASSERT_EQUAL_INT_MESSAGE(n_hosts, count, + "checkpoint must contain exactly one HostAdminState frame per host " + "currently in the live workermgr table -- collect_host_frames " + "applies no retention predicate, mirroring collect_queue_frames"); + + for (int i = 0; i < n_hosts; i++) { + char expect_name[32]; + snapent_host_name(i, expect_name, sizeof(expect_name)); + + snapent_decoded_host_t* dh = + snapent_find_decoded_host(decoded, count, expect_name); + TEST_ASSERT_NOT_NULL_MESSAGE(dh, + "checkpoint's raw host frames must contain the seeded host"); + + uint32_t expect_admin = snapent_host_admin_status(i); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(expect_admin, dh->status, + "the frame's status must be EXACTLY the admin-issued bits -- " + "neither missing an admin bit nor carrying a dynamic bit " + "(Requirement 1.6, 1.7)"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, + dh->status & SNAPENT_HOST_DYNAMIC_BITS, + "the frame must never carry the seeded host's dynamic/liveness " + "bits, even though they are set on the live worker_t " + "(Requirement 1.7)"); + } + + snapent_teardown(src); + printf("test_snapent_checkpoint_host_admin_status_roundtrip: PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Test 6c (task 2.1): the task's own literal Observable criterion -- a */ +/* checkpoint taken while exactly one host exists, administratively */ +/* closed, contains exactly one HostAdminState frame for it, with ONLY */ +/* the admin bit set (no dynamic fields). */ +/* ------------------------------------------------------------------ */ + +static void test_snapent_checkpoint_closed_host_yields_exactly_one_admin_only_frame(void) +{ + snapent_fixture_t* src = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(src); + + const char* hostname = "snapent-closed-host"; + hostattrs_t attrs; + memset(&attrs, 0, sizeof(attrs)); + + xce_worker_t hdef; + memset(&hdef, 0, sizeof(hdef)); + hdef.name = (char*)hostname; + hdef.attrs = &attrs; + hdef.status = XCE_WORKER_CLOSED_BY_ADMIN | SNAPENT_HOST_DYNAMIC_BITS; + + TEST_ASSERT_NOT_NULL(makeWorker(src->c->workermgr, &hdef)); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist)); + + char path[4096]; + snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp", + src->c->persist.snap_path, src->c->persist.checkpoint_lsn); + + snapent_decoded_host_t decoded[4]; + int count = snapent_read_checkpoint_hosts(path, decoded, 4); + TEST_ASSERT_TRUE(count >= 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, count, + "a checkpoint taken while exactly one host exists (administratively " + "closed) must contain exactly one HostAdminState frame for it"); + + TEST_ASSERT_EQUAL_STRING(hostname, decoded[0].name); + TEST_ASSERT_EQUAL_UINT32_MESSAGE((uint32_t)XCE_WORKER_CLOSED_BY_ADMIN, + decoded[0].status, + "the frame's status must contain ONLY the admin bit -- none of the " + "host's dynamic fields/bits"); + + snapent_teardown(src); + printf("test_snapent_checkpoint_closed_host_yields_exactly_one_admin_only_frame: PASS\n"); +} + +/* ------------------------------------------------------------------ */ +/* Test 6d (task 2.1): no hosts in the live table -> an empty, but */ +/* well-formed, host section (not an error, not a phantom frame). */ +/* ------------------------------------------------------------------ */ + +static void test_snapent_checkpoint_no_hosts_yields_empty_host_section(void) +{ + snapent_fixture_t* src = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(src); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist)); + + char path[4096]; + snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp", + src->c->persist.snap_path, src->c->persist.checkpoint_lsn); + + snapent_decoded_host_t decoded[4]; + int count = snapent_read_checkpoint_hosts(path, decoded, 4); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, count, + "a checkpoint taken with no hosts in the live workermgr table must " + "have an empty host section, not an error and not a phantom frame"); + + snapent_teardown(src); + printf("test_snapent_checkpoint_no_hosts_yields_empty_host_section: PASS\n"); +} + /* ─────────────────────────────────────────────────────────────────────── * ha-entity-snapshots Task 4.2 (Req 3.1-3.3): rotation-guard RED test. * @@ -1307,6 +1698,9 @@ void test_persist_snapshot_entities(void) RUN_TEST(test_snapent_checkpoint_write_failure_leaves_no_final_file); RUN_TEST(test_snapent_checkpoint_mid_encode_failure_discards_whole_file); RUN_TEST(test_snapent_checkpoint_queue_status_roundtrip); + RUN_TEST(test_snapent_checkpoint_host_admin_status_roundtrip); + RUN_TEST(test_snapent_checkpoint_closed_host_yields_exactly_one_admin_only_frame); + RUN_TEST(test_snapent_checkpoint_no_hosts_yields_empty_host_section); RUN_TEST(test_snapent_rotation_preserves_in_flight_checkpoint); RUN_TEST(test_snapent_load_snapshot_restores_entities_before_advancing_position); RUN_TEST(test_snapent_load_snapshot_corrupted_entity_leaves_position_unchanged);