load_snapshot_entities' queue-frame decode migrates from the old raw
{name_len,name,status} layout to {payload_len, capnp QueueAdminState
bytes}, still calling the unmodified restore_queue_state. Adds a new
host-frame section decode after queues, dispatching to
restore_host_state (worker.c, task 3) with the same bounds-checking
and restore-before-advance discipline as the existing job/queue loops.
Un-ignores test_snapent_load_snapshot_restores_entities_before_advancing_position
(blocked since task 2.2) -- now passes for real. Also fixes a genuine
regression in test_snapshot_recv_out_of_order_chunks_assemble_correctly
(test_config_repl.c): its synthetic checkpoint-body filler landed on
the new host_count field's offset, tripping the cluster==NULL guard --
pinned to a well-formed host_count=0, matching that test's existing
fmt/job_count/queue_count precedent. Task 4 of 19.
Requirements: 1.10
2234 lines
95 KiB
C
2234 lines
95 KiB
C
/*
|
|
* test_persist_snapshot_entities.c — entity-bearing checkpoint test suite
|
|
* (ha-entity-snapshots).
|
|
*
|
|
* Task 1.1 (Requirements 1.1, 2.1): a reusable test fixture wrapping a real
|
|
* persist_t embedded in a minimal test cluster_t whose jobmgr/qmgr tables are
|
|
* seeded with N jobs (varying state) and M queues (varying status) — the
|
|
* foundation later tasks in this file build on:
|
|
* - 2.1/2.2: write_snapshot/load_snapshot job round-trip, per-entity
|
|
* consistency, partial-write discard
|
|
* - 3.1/3.2: queue admin-status round-trip
|
|
* - 4.2: rotation-guard (in-flight checkpoint survives rotate_snapshots)
|
|
* - 5.1/5.2: restore-before-advance, failure-safety, legacy-format handling
|
|
*
|
|
* Modeled on:
|
|
* - test_persist_hooks.c's make_hook_test_persist/cleanup_hook_test_persist
|
|
* (bare persist_t over a private tmpdir, no HA/commit-engine wiring)
|
|
* - test_commit_barrier_reply.c's cbrp_setup / test_egress_filter.c's
|
|
* ef_setup (persist_t embedded in a real cluster_t with jobmgr/qmgr wired
|
|
* as xkvmgr instances, back-pointer registered via persist_init so
|
|
* persist_start can register hooks)
|
|
*
|
|
* Deliberately OMITTED vs. cbrp_setup/ef_setup: commit engine, standby
|
|
* registry, repl_broadcast_send_hook, and the LSN-0 priming append. None of
|
|
* those are needed here — create_job/makeJob and makeQueue insert directly
|
|
* into the cluster's tables with no WAL append or replication involved, and
|
|
* write_snapshot/persist_checkpoint never call repl_broadcast (checkpoint
|
|
* markers are local-only; see persist.c persist_write_checkpoint_marker).
|
|
* Adding that machinery here would be unused scaffolding.
|
|
*
|
|
* job_t/queue_t insertion mirrors the established test idiom:
|
|
* - ef_make_job (test_egress_filter.c): zero an xce_job_t, set
|
|
* jobid/state/submit, call makeJob(jobmgr, &jdef) — makeJob deep-copies
|
|
* the submit payload (jobj.c job_init -> makeJobsub), so a single reused
|
|
* scratch job_submit_data_t is safe across a seeding loop.
|
|
* - cbrp_arrange_queuectrl (test_commit_barrier_reply.c): zero an
|
|
* xce_queue_t, set queue (name) + status, call makeQueue(qmgr, &qdef) —
|
|
* queue_init deep-copies the name via makeString, and the underlying
|
|
* string-keyed kvtab strdup's the key on insert (kv.c assign_string_key),
|
|
* so a single reused scratch name buffer is likewise safe.
|
|
*
|
|
* NOTE (design.md / bug investigation 2026-06-30): queues are config-
|
|
* provisioned at startup, not created from WAL/snapshot data — restore_queue_state
|
|
* can only re-apply status to a queue that already exists in qmgr. Seeding
|
|
* queues directly here (as makeQueue does) stands in for that config-load
|
|
* step, matching what a real config-provisioned queue table looks like by
|
|
* the time write_snapshot/load_snapshot ever touch it.
|
|
*/
|
|
|
|
#define _GNU_SOURCE
|
|
#include <rtti.h>
|
|
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <openssl/sha.h>
|
|
#include <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#include <unity/unity.h>
|
|
|
|
#include "cluster.h"
|
|
#include "config_repl.h"
|
|
#include "controller.capnp.h"
|
|
#include "jobj.h"
|
|
#include "myconf.h"
|
|
#include "qobj.h"
|
|
#include "wobj.h"
|
|
#include "xce/cluster_def.h"
|
|
#include "xce/types.h"
|
|
#include "xstring.h"
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Deterministic seed data — later tests predict seeded values from the */
|
|
/* loop index via these helpers rather than the fixture returning arrays */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SNAPENT_JOBID_BASE (1000ULL)
|
|
|
|
static const unsigned int g_snapent_job_states[] = {
|
|
JOB_STAT_PEND, JOB_STAT_RUN, JOB_STAT_EXIT, JOB_STAT_DONE, JOB_STAT_PSUSP,
|
|
};
|
|
#define SNAPENT_N_JOB_STATES \
|
|
(sizeof(g_snapent_job_states) / sizeof(g_snapent_job_states[0]))
|
|
|
|
static const uint32_t g_snapent_queue_statuses[] = {
|
|
QUEUE_STAT_OPEN, QUEUE_STAT_CLOSED, QUEUE_STAT_INACT_ADMIN,
|
|
(QUEUE_STAT_CLOSED | QUEUE_STAT_INACT_ADMIN),
|
|
};
|
|
#define SNAPENT_N_QUEUE_STATUSES \
|
|
(sizeof(g_snapent_queue_statuses) / sizeof(g_snapent_queue_statuses[0]))
|
|
|
|
static uint64_t snapent_jobid(int i)
|
|
{
|
|
return SNAPENT_JOBID_BASE + (uint64_t)i;
|
|
}
|
|
|
|
static unsigned int snapent_job_state(int i)
|
|
{
|
|
return g_snapent_job_states[i % (int)SNAPENT_N_JOB_STATES];
|
|
}
|
|
|
|
static void snapent_queue_name(int i, char* buf, size_t buf_sz)
|
|
{
|
|
snprintf(buf, buf_sz, "snapent-q%d", i);
|
|
}
|
|
|
|
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)
|
|
|
|
/* Mask covering exactly the bits g_snapent_host_admin_statuses ever
|
|
* combines (config-upgrade-snapshot task 4's end-to-end restore test) --
|
|
* disjoint from SNAPENT_HOST_DYNAMIC_BITS above by construction, so a
|
|
* restored worker's status can be checked for "contains exactly these
|
|
* admin bits" without depending on persist.c's own internal
|
|
* PERSIST_WORKER_ADMIN_MASK #define. */
|
|
#define SNAPENT_HOST_ADMIN_STATUS_MASK \
|
|
(XCE_WORKER_CLOSED_BY_ADMIN | XCE_WORKER_LOCKEDU | XCE_WORKER_HIDDEN)
|
|
|
|
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
|
|
* Job capnp schema actually carries (JobSubmitData: queue, user, ...,
|
|
* JobStartInfo: cmdline, ...; src/schema/controller.capnp) are populated. */
|
|
static void snapent_job_queue_name(int i, char* buf, size_t buf_sz)
|
|
{
|
|
snprintf(buf, buf_sz, "snapent-jobqueue-%d", i);
|
|
}
|
|
|
|
static void snapent_job_user(int i, char* buf, size_t buf_sz)
|
|
{
|
|
snprintf(buf, buf_sz, "snapent-user-%d", i);
|
|
}
|
|
|
|
static void snapent_job_cmdline(int i, char* buf, size_t buf_sz)
|
|
{
|
|
snprintf(buf, buf_sz, "/bin/snapent-cmd-%d --arg=%d", i, i);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Fixture: persist_t + cluster_t with seeded job/queue tables */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
typedef struct {
|
|
cluster_t* c;
|
|
char tmpdir[256];
|
|
/* Only populated/wired (f->c->myconf = &f->myconf) by tests that call
|
|
* snapent_wire_myconf -- task 2.3's config-bytes-section tests. Every
|
|
* other existing test leaves f->c->myconf NULL, exactly as before this
|
|
* field was added (zero footprint on already-passing tests). */
|
|
myconf_t myconf;
|
|
} snapent_fixture_t;
|
|
|
|
/* jobj.c's jobsub/startinfo tables are process-global and idempotently
|
|
* guarded — mirror the per-file static flag used by test_egress_filter.c /
|
|
* test_commit_barrier_reply.c so this file can run standalone or alongside
|
|
* them in the same controller_unit_test binary. */
|
|
static int g_snapent_jobobj_ready;
|
|
|
|
static void snapent_seed_jobs(cluster_t* c, int n)
|
|
{
|
|
static job_submit_data_t submit_scratch;
|
|
static job_startinfo_t startinfo_scratch;
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
xce_job_t jdef;
|
|
char queue_buf[48], user_buf[48], cmdline_buf[96];
|
|
|
|
memset(&jdef, 0, sizeof(jdef));
|
|
memset(&submit_scratch, 0, sizeof(submit_scratch));
|
|
memset(&startinfo_scratch, 0, sizeof(startinfo_scratch));
|
|
|
|
snapent_job_queue_name(i, queue_buf, sizeof(queue_buf));
|
|
snapent_job_user(i, user_buf, sizeof(user_buf));
|
|
snapent_job_cmdline(i, cmdline_buf, sizeof(cmdline_buf));
|
|
|
|
/* makeJob -> job_init -> makeJobsub deep-copies these via makeString
|
|
* before this function returns, so the stack scratch buffers only
|
|
* need to stay valid for the duration of the makeJob call below. */
|
|
startinfo_scratch.cmdline = cmdline_buf;
|
|
submit_scratch.queue = queue_buf;
|
|
submit_scratch.user = user_buf;
|
|
submit_scratch.start_info = &startinfo_scratch;
|
|
|
|
jdef.jobid = snapent_jobid(i);
|
|
jdef.state = snapent_job_state(i);
|
|
jdef.submit = &submit_scratch;
|
|
|
|
/* job_should_retain (persist.c) excludes a TERMINAL job once its
|
|
* finish_time + finished_retain_s has elapsed -- a never-set (0)
|
|
* finish_time looks like "finished at the epoch", so a freshly
|
|
* seeded terminal-state job (EXIT/DONE) would be silently excluded
|
|
* from the checkpoint under the default finished_retain_s window.
|
|
* Stamp a fresh finish_time so seeded terminal jobs are retained
|
|
* exactly like a job that just finished -- matching what
|
|
* job_should_retain actually gates on now that write_snapshot
|
|
* applies it (ha-entity-snapshots task 2.2). */
|
|
if (jdef.state & (JOB_STAT_EXIT | JOB_STAT_DONE | JOB_STAT_PERR))
|
|
jdef.finish_time = time(NULL);
|
|
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(makeJob(c->jobmgr, &jdef),
|
|
"seed job insertion must succeed");
|
|
}
|
|
}
|
|
|
|
static void snapent_seed_queues(cluster_t* c, int m)
|
|
{
|
|
for (int i = 0; i < m; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
|
|
xce_queue_t qdef;
|
|
memset(&qdef, 0, sizeof(qdef));
|
|
qdef.queue = name; /* deep-copied via makeString in queue_init */
|
|
qdef.status = snapent_queue_status(i);
|
|
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(makeQueue(c->qmgr, &qdef),
|
|
"seed queue insertion must succeed");
|
|
}
|
|
}
|
|
|
|
/* 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. */
|
|
static snapent_fixture_t* snapent_make(int n_jobs, int m_queues)
|
|
{
|
|
kvmgr_params_t int_params = { .key_type = KVMGR_KEY_INT,
|
|
.nslots = 64,
|
|
.delta = 16 };
|
|
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)
|
|
return NULL;
|
|
|
|
f->c = calloc(1, sizeof(cluster_t));
|
|
if (f->c == NULL) {
|
|
free(f);
|
|
return NULL;
|
|
}
|
|
|
|
f->c->myhost = &f->c->myhost_info;
|
|
f->c->myhost_info.name = (char*)"snapent-test";
|
|
f->c->myhost_info.key = (char*)"";
|
|
|
|
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->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) {
|
|
TEST_ASSERT_EQUAL_INT(0, job_obj_init(64, 64));
|
|
g_snapent_jobobj_ready = 1;
|
|
}
|
|
|
|
persist_config_t cfg = persist_config_defaults();
|
|
cfg.wal_sync_ms = 5000; /* slow background fsync — avoid races */
|
|
|
|
snprintf(f->tmpdir, sizeof(f->tmpdir), "/tmp/xce_snapent_XXXXXX");
|
|
TEST_ASSERT_NOT_NULL(mkdtemp(f->tmpdir));
|
|
persist_set_workdir(&f->c->persist, f->tmpdir, "snapent");
|
|
TEST_ASSERT_EQUAL_INT(0, persist_init(&f->c->persist, &cfg, f->c));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_start(&f->c->persist));
|
|
|
|
snapent_seed_jobs(f->c, n_jobs);
|
|
snapent_seed_queues(f->c, m_queues);
|
|
|
|
return f;
|
|
}
|
|
|
|
/* Wire a real myconf_t onto the fixture's cluster (config-upgrade-snapshot
|
|
* task 2.3): f->myconf.workdir/name are set to the SAME (f->tmpdir,
|
|
* "snapent") pair persist_set_workdir already used above -- exactly mirroring
|
|
* production wiring, where a single controller instance's persist_set_workdir
|
|
* and c->myconf->workdir/name are literally the same values (cluster.c:1408-
|
|
* 1441). effective_dir = workdir/name is therefore shared between persist's
|
|
* OWN data/{wal,snap} subtree and myconf's conf/ subtree with no collision
|
|
* (different subdirectories under the same instance root), the same layout a
|
|
* real controller process has on disk.
|
|
*
|
|
* Does NOT write any config bytes itself -- callers that need real bytes on
|
|
* disk call conf_save_config() afterward (config_repl.h); a caller that
|
|
* wires myconf but never saves anything is deliberately exercising the
|
|
* "live cluster, no config pushed yet" -ENOENT path in
|
|
* read_active_config_bytes (persist.c). */
|
|
static void snapent_wire_myconf(snapent_fixture_t* f)
|
|
{
|
|
f->myconf.workdir = f->tmpdir;
|
|
f->myconf.name = (char*)"snapent";
|
|
f->myconf.conf_keep_n = 2;
|
|
f->c->myconf = &f->myconf;
|
|
}
|
|
|
|
static void snapent_teardown(snapent_fixture_t* f)
|
|
{
|
|
if (f == NULL)
|
|
return;
|
|
|
|
persist_stop(&f->c->persist);
|
|
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->workermgr) dereference(f->c->workermgr);
|
|
xce_hook_fini(&f->c->hooksys);
|
|
|
|
/* free(NULL) is safe -- conffile is only heap-allocated (via
|
|
* conf_save_config's asprintf) when a test actually wired+saved a real
|
|
* config; every other test leaves it at its calloc'd NULL. */
|
|
free(f->myconf.conffile);
|
|
|
|
free(f->c);
|
|
free(f);
|
|
}
|
|
|
|
/* Lookup helpers — mirror ef_find_job (test_egress_filter.c). */
|
|
static job_t* snapent_find_job(cluster_t* c, uint64_t jobid)
|
|
{
|
|
job_t* j = NULL;
|
|
int found = 0;
|
|
|
|
lock(c->jobmgr, RTTI_LOCK_READ);
|
|
found = key_find(c->jobmgr, (void*)jobid, (void**)&j);
|
|
unlock(c->jobmgr);
|
|
|
|
return (found > 0) ? j : NULL;
|
|
}
|
|
|
|
static queue_t* snapent_find_queue(cluster_t* c, const char* name)
|
|
{
|
|
queue_t* q = NULL;
|
|
int found = 0;
|
|
|
|
lock(c->qmgr, RTTI_LOCK_READ);
|
|
found = key_find(c->qmgr, (void*)name, (void**)&q);
|
|
unlock(c->qmgr);
|
|
|
|
return (found > 0) ? q : NULL;
|
|
}
|
|
|
|
/* Lookup helper for host restore assertions (config-upgrade-snapshot task 4)
|
|
* -- mirrors snapent_find_job/snapent_find_queue above exactly, over
|
|
* c->workermgr. */
|
|
static worker_t* snapent_find_worker(cluster_t* c, const char* name)
|
|
{
|
|
worker_t* w = NULL;
|
|
int found = 0;
|
|
|
|
lock(c->workermgr, RTTI_LOCK_READ);
|
|
found = key_find(c->workermgr, (void*)name, (void**)&w);
|
|
unlock(c->workermgr);
|
|
|
|
return (found > 0) ? w : NULL;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 1: fixture seeds N jobs (varying state) + M queues (varying */
|
|
/* status) and both are independently observable via key_find */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_fixture_seeds_jobs_and_queues(void)
|
|
{
|
|
const int n_jobs = 5;
|
|
const int m_queues = 4;
|
|
|
|
snapent_fixture_t* f = snapent_make(n_jobs, m_queues);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
TEST_ASSERT_NOT_NULL(f->c);
|
|
|
|
/* persist_init must have registered the cluster back-pointer — this is
|
|
* what lets write_snapshot/load_snapshot reach c->jobmgr/c->qmgr. */
|
|
TEST_ASSERT_EQUAL_PTR(f->c, f->c->persist.cluster);
|
|
|
|
for (int i = 0; i < n_jobs; i++) {
|
|
job_t* j = snapent_find_job(f->c, snapent_jobid(i));
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(j, "seeded job must be present in jobmgr");
|
|
TEST_ASSERT_EQUAL_UINT64(snapent_jobid(i), j->job.jobid);
|
|
TEST_ASSERT_EQUAL_UINT32(snapent_job_state(i), j->job.state);
|
|
}
|
|
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
|
|
queue_t* q = snapent_find_queue(f->c, name);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(q, "seeded queue must be present in qmgr");
|
|
TEST_ASSERT_EQUAL_UINT32(snapent_queue_status(i), q->queue.status);
|
|
}
|
|
|
|
/* Sanity: lookups for entities that were never seeded must miss. */
|
|
TEST_ASSERT_NULL(snapent_find_job(f->c, snapent_jobid(n_jobs) + 999));
|
|
TEST_ASSERT_NULL(snapent_find_queue(f->c, "snapent-does-not-exist"));
|
|
|
|
snapent_teardown(f);
|
|
printf("test_snapent_fixture_seeds_jobs_and_queues: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test-local raw checkpoint reader (task 2.2 remediation). */
|
|
/* */
|
|
/* This task's boundary is write_snapshot ONLY -- load_snapshot's entity */
|
|
/* restore is task 5.x's job (see design.md Requirement 2). The tests */
|
|
/* below must prove write_snapshot's on-disk output is field-identical */
|
|
/* WITHOUT routing through persist_load_snapshot/load_snapshot, so they */
|
|
/* parse the checkpoint's raw bytes directly here and decode each job */
|
|
/* frame with the SAME capnp helpers restore_job (job.c) uses */
|
|
/* (read_Job + decode_Job from controller.capnp.h) -- proving the */
|
|
/* WRITE side without any dependency on the READ side's future behavior. */
|
|
/* */
|
|
/* Byte layout mirrors design.md "Checkpoint File Layout (Physical)": */
|
|
/* [57-byte legacy sentinel: magic(8)+lsn(8)+cfgref tail(1+8+32)] */
|
|
/* [uint32_t format_version][uint32_t job_count] */
|
|
/* [job_count * { uint32_t payload_len; capnp Job bytes }] */
|
|
/* write_snapshot (persist.c) always emits the full 57-byte sentinel */
|
|
/* unconditionally (memset + full memcpy, no short-write branch for an */
|
|
/* absent cfgref), so skipping a fixed 57 bytes here is safe -- unlike */
|
|
/* load_snapshot's OWN reader, which must also tolerate a *shorter* */
|
|
/* 16-byte legacy sentinel from files predating the cfgref tail. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SNAPENT_LEGACY_HEADER_BYTES (16 + 1 + 8 + 32)
|
|
#define SNAPENT_TEST_FORMAT_VERSION 2u /* must match persist.c's SNAPSHOT_FORMAT_VERSION
|
|
* (config-upgrade-snapshot task 1 bumped 1->2) */
|
|
#define SNAPENT_MAX_DECODE_PAYLOAD (16u * 1024 * 1024)
|
|
|
|
/* Decodes every job frame in the checkpoint at `path` into `out`
|
|
* (caller-allocated array of `max_out` xce_job_t slots). Returns the
|
|
* number of frames decoded (<= job_count on disk, capped at max_out), or
|
|
* -1 on any structural read error (bad magic, bad/absent format_version,
|
|
* short read, oversized payload_len). Each decoded slot in `out` up to
|
|
* the returned count must be released by the caller via free_Job(). */
|
|
static int snapent_read_checkpoint_jobs(const char* path, xce_job_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;
|
|
}
|
|
|
|
uint32_t job_count = 0;
|
|
if (read(fd, &job_count, sizeof(job_count)) != (ssize_t)sizeof(job_count)) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
|
|
int n = 0;
|
|
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* 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);
|
|
Job_ptr jp;
|
|
jp.p = capn_getp(capn_root(&c_ctx), 0, 1);
|
|
struct Job cj;
|
|
memset(&out[n], 0, sizeof(out[n]));
|
|
read_Job(&cj, jp);
|
|
decode_Job(&out[n], &cj);
|
|
capn_free(&c_ctx);
|
|
n++;
|
|
}
|
|
free(buf);
|
|
}
|
|
|
|
close(fd);
|
|
return n;
|
|
}
|
|
|
|
static xce_job_t* snapent_find_decoded_job(xce_job_t* jobs, int count,
|
|
uint64_t jobid)
|
|
{
|
|
for (int i = 0; i < count; i++) {
|
|
if (jobs[i].jobid == jobid)
|
|
return &jobs[i];
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static void snapent_free_decoded_jobs(xce_job_t* jobs, int count)
|
|
{
|
|
for (int i = 0; i < count; i++)
|
|
free_Job(&jobs[i]);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 2 (task 2.1/2.2): checkpoint job content round-trips through */
|
|
/* write_snapshot's own on-disk bytes field-identical (Requirements 1.1, */
|
|
/* 1.2, 1.4). Parses the raw checkpoint file directly (see reader above)*/
|
|
/* rather than via persist_load_snapshot -- load_snapshot's entity */
|
|
/* restore is task 5.x's boundary, not this task's. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_checkpoint_job_content_roundtrip(void)
|
|
{
|
|
const int n_jobs = 4;
|
|
|
|
snapent_fixture_t* src = snapent_make(n_jobs, 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);
|
|
|
|
xce_job_t decoded[8];
|
|
int count = snapent_read_checkpoint_jobs(path, decoded, 8);
|
|
TEST_ASSERT_TRUE_MESSAGE(count >= 0,
|
|
"checkpoint file must parse as a well-formed entity-bearing "
|
|
"format_version=1 payload");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(n_jobs, count,
|
|
"checkpoint must contain exactly the retained seeded jobs");
|
|
|
|
for (int i = 0; i < n_jobs; i++) {
|
|
char expect_queue[48], expect_user[48], expect_cmdline[96];
|
|
snapent_job_queue_name(i, expect_queue, sizeof(expect_queue));
|
|
snapent_job_user(i, expect_user, sizeof(expect_user));
|
|
snapent_job_cmdline(i, expect_cmdline, sizeof(expect_cmdline));
|
|
|
|
xce_job_t* dj = snapent_find_decoded_job(decoded, count,
|
|
snapent_jobid(i));
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(dj,
|
|
"checkpoint's raw job frames must contain the seeded job");
|
|
|
|
TEST_ASSERT_EQUAL_UINT64(snapent_jobid(i), dj->jobid);
|
|
TEST_ASSERT_EQUAL_UINT32(snapent_job_state(i), dj->state);
|
|
TEST_ASSERT_NOT_NULL(dj->submit);
|
|
TEST_ASSERT_EQUAL_STRING(expect_queue, dj->submit->queue);
|
|
TEST_ASSERT_EQUAL_STRING(expect_user, dj->submit->user);
|
|
TEST_ASSERT_NOT_NULL(dj->submit->start_info);
|
|
TEST_ASSERT_EQUAL_STRING(expect_cmdline,
|
|
dj->submit->start_info->cmdline);
|
|
}
|
|
|
|
snapent_free_decoded_jobs(decoded, count);
|
|
snapent_teardown(src);
|
|
printf("test_snapent_checkpoint_job_content_roundtrip: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 3 (task 2.1, RED): concurrent per-entity mutation during the */
|
|
/* checkpoint walk must never be captured as a torn mix of fields */
|
|
/* (Requirement 1.2). */
|
|
/* */
|
|
/* design.md "Existing Architecture Analysis": the table RTTI_LOCK_READ */
|
|
/* held by collect_jobs alone does NOT exclude a concurrent per-entity */
|
|
/* writer -- entity mutation takes a SEPARATE per-entity lock (mirrored */
|
|
/* here via job.c's own on_job_start / mark_job_start shape: state and */
|
|
/* start_time flip together under lock(j, RTTI_LOCK_WRITE)). Proving */
|
|
/* "never torn" requires a genuinely concurrent mutation, not a */
|
|
/* sequential one dressed up as concurrent. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SNAPENT_TORN_PRE_STATE JOB_STAT_PEND
|
|
#define SNAPENT_TORN_POST_STATE JOB_STAT_RUN
|
|
#define SNAPENT_TORN_POST_START_TIME ((time_t)987654321)
|
|
|
|
typedef struct {
|
|
cluster_t* c;
|
|
uint64_t jobid;
|
|
atomic_int done;
|
|
} snapent_mutator_arg_t;
|
|
|
|
/* Runs on a background thread concurrently with the main thread's *
|
|
* checkpoint-and-reload loop below. Flips BOTH state and start_time *
|
|
* together under the job's OWN per-entity write lock -- the same pairing *
|
|
* mark_job_start (job.c) updates atomically on a real START transition -- *
|
|
* so a "torn" restore would show up as one field from the pre-transition *
|
|
* tuple mixed with one field from the post-transition tuple. */
|
|
static void* snapent_mutate_job_thread(void* arg)
|
|
{
|
|
snapent_mutator_arg_t* a = (snapent_mutator_arg_t*)arg;
|
|
|
|
job_t* j = snapent_find_job(a->c, a->jobid);
|
|
if (j != NULL) {
|
|
/* Give the main thread's loop below a real chance to observe both
|
|
* the pre- and post-transition tuple across its iterations. */
|
|
usleep(2000);
|
|
|
|
lock(j, RTTI_LOCK_WRITE);
|
|
j->job.state = SNAPENT_TORN_POST_STATE;
|
|
j->job.start_time = SNAPENT_TORN_POST_START_TIME;
|
|
unlock(j);
|
|
}
|
|
|
|
atomic_store(&a->done, 1);
|
|
return NULL;
|
|
}
|
|
|
|
static void test_snapent_checkpoint_concurrent_mutation_not_torn(void)
|
|
{
|
|
const uint64_t jobid = snapent_jobid(0);
|
|
|
|
snapent_fixture_t* src = snapent_make(1, 0);
|
|
TEST_ASSERT_NOT_NULL(src);
|
|
|
|
job_t* j0 = snapent_find_job(src->c, jobid);
|
|
TEST_ASSERT_NOT_NULL(j0);
|
|
TEST_ASSERT_EQUAL_UINT32((uint32_t)SNAPENT_TORN_PRE_STATE, j0->job.state);
|
|
TEST_ASSERT_EQUAL_INT(0, (int)j0->job.start_time);
|
|
|
|
snapent_mutator_arg_t marg;
|
|
marg.c = src->c;
|
|
marg.jobid = jobid;
|
|
atomic_init(&marg.done, 0);
|
|
|
|
pthread_t th;
|
|
TEST_ASSERT_EQUAL_INT(0, pthread_create(&th, NULL,
|
|
snapent_mutate_job_thread, &marg));
|
|
|
|
bool found_any = false;
|
|
bool found_torn = false;
|
|
|
|
for (int iter = 0; iter < 100 && !atomic_load(&marg.done); iter++) {
|
|
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);
|
|
|
|
/* Parse the raw checkpoint bytes directly (see reader above) --
|
|
* this proves write_snapshot's own encode of the job is never torn,
|
|
* independent of load_snapshot's future restore behavior (task
|
|
* 5.x's boundary, not this task's). */
|
|
xce_job_t decoded[4];
|
|
int count = snapent_read_checkpoint_jobs(path, decoded, 4);
|
|
TEST_ASSERT_TRUE_MESSAGE(count >= 0,
|
|
"checkpoint file must parse as a well-formed entity-bearing "
|
|
"format_version=1 payload");
|
|
|
|
xce_job_t* dj = snapent_find_decoded_job(decoded, count, jobid);
|
|
if (dj != NULL) {
|
|
found_any = true;
|
|
|
|
bool is_pre = (dj->state == (uint32_t)SNAPENT_TORN_PRE_STATE
|
|
&& dj->start_time == 0);
|
|
bool is_post = (dj->state == (uint32_t)SNAPENT_TORN_POST_STATE
|
|
&& dj->start_time == SNAPENT_TORN_POST_START_TIME);
|
|
if (!is_pre && !is_post)
|
|
found_torn = true;
|
|
}
|
|
|
|
snapent_free_decoded_jobs(decoded, count);
|
|
}
|
|
|
|
pthread_join(th, NULL);
|
|
|
|
TEST_ASSERT_FALSE_MESSAGE(found_torn,
|
|
"checkpoint captured a torn mix of job state/start_time -- neither "
|
|
"fully the pre-transition nor fully the post-transition tuple");
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(found_any,
|
|
"a checkpoint must contain the seeded job's entity state at least "
|
|
"once across the loop");
|
|
|
|
snapent_teardown(src);
|
|
printf("test_snapent_checkpoint_concurrent_mutation_not_torn: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 4 (task 2.1): a real write-path I/O failure must leave NO file */
|
|
/* at the final checkpoint path (Requirement 1.3). */
|
|
/* */
|
|
/* NOTE ON RED-NESS: unlike the two tests above, this one is EXPECTED */
|
|
/* TO PASS already today. write_snapshot's existing open-failure and */
|
|
/* tmp-then-rename-failure paths already unlink/never-create the tmp */
|
|
/* file and never touch the final path on error (persist.c ~1487-1518). *
|
|
* design.md's "Existing Architecture Analysis" explicitly notes this */
|
|
/* discard behavior ALREADY exists and is what lets the unchanged */
|
|
/* persist_checkpoint rc-gate (`if (rc != 0) goto done;`) satisfy */
|
|
/* Requirement 3 once entities are added -- so this test locks in that */
|
|
/* existing behavior as a regression guard BEFORE task 2.2 adds a */
|
|
/* multi-frame entity-encoding loop that could accidentally leave a */
|
|
/* partial file behind on a mid-loop failure. */
|
|
/* */
|
|
/* A real, portable fault is used (the snapshot directory is removed, so */
|
|
/* open(tmp_path, O_CREAT|...) fails with ENOENT) -- not a permission-bit */
|
|
/* trick, which would silently no-op under a root test runner. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_checkpoint_write_failure_leaves_no_final_file(void)
|
|
{
|
|
const int n_jobs = 2;
|
|
|
|
snapent_fixture_t* f = snapent_make(n_jobs, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
char snap_path[4096];
|
|
snprintf(snap_path, sizeof(snap_path), "%s", f->c->persist.snap_path);
|
|
|
|
/* Force a real I/O failure inside write_snapshot: remove the directory
|
|
* tmp_path/final_path live under, so open(tmp_path, O_CREAT|...) fails
|
|
* with ENOENT. */
|
|
TEST_ASSERT_EQUAL_INT(0, rmdir(snap_path));
|
|
|
|
int rc = persist_checkpoint(&f->c->persist);
|
|
TEST_ASSERT_TRUE(rc != 0);
|
|
|
|
/* Recreate the directory so teardown doesn't trip over a missing path. */
|
|
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_path, 0700));
|
|
|
|
char final_path[4096], tmp_path[4096];
|
|
snprintf(final_path, sizeof(final_path), "%s/snap-%016" PRIx64 ".capnp",
|
|
snap_path, f->c->persist.checkpoint_lsn);
|
|
snprintf(tmp_path, sizeof(tmp_path), "%s/snap-%016" PRIx64 ".tmp",
|
|
snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
struct stat st;
|
|
int final_rc = stat(final_path, &st);
|
|
TEST_ASSERT_TRUE(final_rc != 0);
|
|
TEST_ASSERT_EQUAL_INT(ENOENT, errno);
|
|
|
|
int tmp_rc = stat(tmp_path, &st);
|
|
TEST_ASSERT_TRUE(tmp_rc != 0);
|
|
|
|
snapent_teardown(f);
|
|
printf("test_snapent_checkpoint_write_failure_leaves_no_final_file: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 5 (task 2.2, GREEN): a genuine mid-encode-loop failure discards */
|
|
/* the WHOLE checkpoint, not just the one job whose encode failed */
|
|
/* (Requirement 1.3). */
|
|
/* */
|
|
/* Unlike test 4 above (which only exercises the pre-existing open() */
|
|
/* failure branch), this drives the NEW per-job encode loop itself via */
|
|
/* the persist_test_set_force_job_encode_fail_at seam -- forcing the */
|
|
/* (0-based) Nth retained job's encode to fail partway through a */
|
|
/* multi-job walk, proving write_snapshot doesn't leave a partial file */
|
|
/* (with only some of the jobs framed) behind on that failure. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_checkpoint_mid_encode_failure_discards_whole_file(void)
|
|
{
|
|
const int n_jobs = 5;
|
|
const int fail_at = 2; /* fail a job in the middle of the walk, not the first */
|
|
|
|
snapent_fixture_t* f = snapent_make(n_jobs, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
persist_test_set_force_job_encode_fail_at(&f->c->persist, fail_at);
|
|
|
|
int rc = persist_checkpoint(&f->c->persist);
|
|
TEST_ASSERT_TRUE_MESSAGE(rc != 0,
|
|
"a forced mid-loop job-encode failure must fail the checkpoint");
|
|
|
|
char final_path[4096], tmp_path[4096];
|
|
snprintf(final_path, sizeof(final_path), "%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
snprintf(tmp_path, sizeof(tmp_path), "%s/snap-%016" PRIx64 ".tmp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
struct stat st;
|
|
TEST_ASSERT_TRUE_MESSAGE(stat(final_path, &st) != 0,
|
|
"a mid-encode failure must leave no file at the final checkpoint path");
|
|
TEST_ASSERT_TRUE_MESSAGE(stat(tmp_path, &st) != 0,
|
|
"a mid-encode failure must not leave the tmp file behind either");
|
|
|
|
/* Disarm before teardown so no later checkpoint in this process forced
|
|
* by teardown/other code paths trips over a stale forced failure. */
|
|
persist_test_set_force_job_encode_fail_at(&f->c->persist, -1);
|
|
|
|
snapent_teardown(f);
|
|
printf("test_snapent_checkpoint_mid_encode_failure_discards_whole_file: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test-local raw checkpoint reader for the queue section */
|
|
/* (config-upgrade-snapshot task 2.2). */
|
|
/* */
|
|
/* This task's boundary is write_snapshot ONLY -- load_snapshot's entity */
|
|
/* restore is task 4's job (design.md Requirement 5.7), mirroring the */
|
|
/* host-section reader below (task 2.1's own remediation note). Reads */
|
|
/* the checkpoint's on-disk bytes directly per design.md's "Logical Data */
|
|
/* Model" checkpoint file layout, SNAPSHOT_FORMAT_VERSION=2: */
|
|
/* [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 * {payload_len; capnp */
|
|
/* QueueAdminState bytes}] */
|
|
/* Migrated from the prior raw {name_len, name, status} layout (Req */
|
|
/* 1.9, 5.7) -- same [payload_len][capnp bytes] shape the job/host */
|
|
/* sections already use. Skips past the job section (whatever it */
|
|
/* contains) to reach the queue section, so this reader works */
|
|
/* regardless of how many jobs a fixture also seeded. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SNAPENT_MAX_QNAME_LEN 63
|
|
|
|
typedef struct {
|
|
char name[SNAPENT_MAX_QNAME_LEN + 1];
|
|
uint32_t status;
|
|
} snapent_decoded_queue_t;
|
|
|
|
/* Reads every queue frame in the checkpoint at `path` into `out`
|
|
* (caller-allocated array of `max_out` slots). Returns the number of
|
|
* frames decoded (<= queue_count on disk, capped at max_out), or -1 on
|
|
* any structural read error (bad magic, bad/absent format_version, short
|
|
* read, oversized job payload_len, oversized queue name_len). */
|
|
static int snapent_read_checkpoint_queues(const char* path,
|
|
snapent_decoded_queue_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 -- this reader only cares about what
|
|
* follows it, but the on-disk layout is sequential so every job frame
|
|
* must be walked past (by its own length prefix) to reach the queue
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/* Queue section (this task's actual boundary): queue_count + per-queue
|
|
* [payload_len][capnp QueueAdminState bytes] -- migrated from the
|
|
* prior raw {name_len, name, status} layout. Decoded via the same
|
|
* generated read_QueueAdminState/decode_QueueAdminState helpers
|
|
* load_snapshot's future restore step (task 4) will use, mirroring
|
|
* how the host-section reader below decodes HostAdminState. */
|
|
uint32_t queue_count = 0;
|
|
if (read(fd, &queue_count, sizeof(queue_count)) !=
|
|
(ssize_t)sizeof(queue_count)) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
|
|
int n = 0;
|
|
for (uint32_t i = 0; i < queue_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);
|
|
QueueAdminState_ptr qp;
|
|
qp.p = capn_getp(capn_root(&c_ctx), 0, 1);
|
|
struct QueueAdminState cq;
|
|
memset(&cq, 0, sizeof(cq));
|
|
read_QueueAdminState(&cq, qp);
|
|
|
|
capnp_queue_admin_state_t decoded;
|
|
memset(&decoded, 0, sizeof(decoded));
|
|
decode_QueueAdminState(&decoded, &cq);
|
|
capn_free(&c_ctx);
|
|
|
|
snprintf(out[n].name, sizeof(out[n].name), "%s",
|
|
decoded.name ? decoded.name : "");
|
|
out[n].status = decoded.status;
|
|
free_QueueAdminState(&decoded);
|
|
n++;
|
|
}
|
|
free(buf);
|
|
}
|
|
|
|
close(fd);
|
|
return n;
|
|
}
|
|
|
|
static snapent_decoded_queue_t* snapent_find_decoded_queue(
|
|
snapent_decoded_queue_t* queues, int count, const char* name)
|
|
{
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(queues[i].name, name) == 0)
|
|
return &queues[i];
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 6 (task 3.1, RED): a queue's (name, status) pair written into */
|
|
/* the checkpoint by write_snapshot is read back byte-identical */
|
|
/* (Requirement 1.1). */
|
|
/* */
|
|
/* write_snapshot currently hardcodes queue_count=0 (persist.c's task */
|
|
/* 3.2 placeholder -- see design.md "write_snapshot (extended)" and the */
|
|
/* Implementation Notes at the bottom of tasks.md) -- so this MUST fail */
|
|
/* today: the checkpoint's queue section is always empty regardless of */
|
|
/* how many queues are actually seeded in the fixture's qmgr. Task 3.2 */
|
|
/* makes this pass by collecting the live queue table into that section.*/
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_checkpoint_queue_status_roundtrip(void)
|
|
{
|
|
const int m_queues = 4;
|
|
|
|
snapent_fixture_t* src = snapent_make(0, m_queues);
|
|
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_queue_t decoded[8];
|
|
int count = snapent_read_checkpoint_queues(path, decoded, 8);
|
|
TEST_ASSERT_TRUE_MESSAGE(count >= 0,
|
|
"checkpoint file must parse as a well-formed entity-bearing "
|
|
"format_version=1 payload through the queue section");
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(m_queues, count,
|
|
"checkpoint must contain exactly the seeded queues -- write_snapshot "
|
|
"currently hardcodes queue_count=0 (task 3.2 not yet implemented)");
|
|
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char expect_name[32];
|
|
snapent_queue_name(i, expect_name, sizeof(expect_name));
|
|
|
|
snapent_decoded_queue_t* dq =
|
|
snapent_find_decoded_queue(decoded, count, expect_name);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(dq,
|
|
"checkpoint's raw queue frames must contain the seeded queue");
|
|
TEST_ASSERT_EQUAL_UINT32(snapent_queue_status(i), dq->status);
|
|
}
|
|
|
|
snapent_teardown(src);
|
|
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 -- migrated to capnp QueueAdminState
|
|
* frames (task 2.2, Req 1.9/5.7); skip shape now matches the job
|
|
* section's [payload_len][capnp bytes] framing exactly, no longer the
|
|
* old raw {name_len, name, status} layout. */
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Reads the trailing config-bytes section a checkpoint file carries
|
|
* (config-upgrade-snapshot task 2.3, Req 2.1, 2.3, 2.4, 5.1): skips past the
|
|
* legacy header + format_version + job/queue/host sections exactly like
|
|
* snapent_read_checkpoint_hosts above, then reads the final
|
|
* [config_bytes_len][raw config bytes] blob -- the single flat section that
|
|
* comes after every other section, never a [count][frame...] shape.
|
|
*
|
|
* On success returns 0 with *out_bytes (caller must free(), NULL when
|
|
* out_len is 0) / *out_len set. Returns -1 on any parse/read failure
|
|
* (malformed header, truncated section, etc.) -- mirrors every other
|
|
* snapent_read_checkpoint_* reader's -1-on-failure convention in this file. */
|
|
static int snapent_read_checkpoint_config_bytes(const char* path,
|
|
uint8_t** out_bytes,
|
|
size_t* out_len)
|
|
{
|
|
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 job, queue, and host sections -- all three share the same
|
|
* [count][payload_len][bytes]* shape by this point (task 2.2 migrated
|
|
* queue frames to capnp, matching job/host). */
|
|
for (int section = 0; section < 3; section++) {
|
|
uint32_t count = 0;
|
|
if (read(fd, &count, sizeof(count)) != (ssize_t)sizeof(count)) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
for (uint32_t i = 0; i < 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Config-bytes section: config_bytes_len + raw bytes (no per-item
|
|
* framing -- exactly one blob for the whole file). */
|
|
uint32_t cfg_len = 0;
|
|
if (read(fd, &cfg_len, sizeof(cfg_len)) != (ssize_t)sizeof(cfg_len)) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
if (cfg_len > SNAPENT_MAX_DECODE_PAYLOAD) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
|
|
uint8_t* buf = NULL;
|
|
if (cfg_len > 0) {
|
|
buf = (uint8_t*)malloc(cfg_len);
|
|
if (buf == NULL) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
if (read(fd, buf, cfg_len) != (ssize_t)cfg_len) {
|
|
free(buf);
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
close(fd);
|
|
*out_bytes = buf;
|
|
*out_len = (size_t)cfg_len;
|
|
return 0;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
/* ───────────────────────────────────────────────────────────────────────
|
|
* config-upgrade-snapshot Task 2.3 (Req 2.1, 2.3, 2.4, 5.1): config-bytes
|
|
* section content, absence, and determinism.
|
|
* ─────────────────────────────────────────────────────────────────────── */
|
|
|
|
/* A config file test payload -- content is irrelevant to write_snapshot
|
|
* (it reads raw bytes, never parses/interprets them), so any distinctive
|
|
* byte string proves the round-trip without needing a real ClusterConfig
|
|
* capnp blob. */
|
|
static const char SNAPENT_CONFIG_TEST_PAYLOAD[] =
|
|
"snapent-config-upgrade-snapshot-task-2.3-test-payload";
|
|
|
|
/* Saves SNAPENT_CONFIG_TEST_PAYLOAD as f's currently-active config via the
|
|
* real conf_save_config path (config_repl.c) -- the exact mechanism
|
|
* read_active_config_bytes (persist.c) resolves against (conf/current ->
|
|
* conf/cluster.bin.<hex>). Requires snapent_wire_myconf to have been called
|
|
* on f first. */
|
|
static void snapent_save_test_config(snapent_fixture_t* f)
|
|
{
|
|
uint8_t sha[32];
|
|
SHA256((const unsigned char*)SNAPENT_CONFIG_TEST_PAYLOAD,
|
|
sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, sha);
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
conf_save_config(&f->myconf, (const uint8_t*)SNAPENT_CONFIG_TEST_PAYLOAD,
|
|
sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, sha));
|
|
}
|
|
|
|
/* Task 2.3's own literal Observable: a checkpoint's config-bytes section
|
|
* content hash matches the currently active configuration -- checked here
|
|
* via an exact byte-for-byte memcmp against the source file's own bytes,
|
|
* which subsumes a hash match (identical bytes trivially hash identically,
|
|
* and is a strictly stronger, more direct assertion). */
|
|
static void test_snapent_checkpoint_config_bytes_section_matches_source_file(void)
|
|
{
|
|
snapent_fixture_t* f = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
snapent_wire_myconf(f);
|
|
snapent_save_test_config(f);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist));
|
|
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
uint8_t* section_bytes = NULL;
|
|
size_t section_len = 0;
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
|
|
snapent_read_checkpoint_config_bytes(path, §ion_bytes, §ion_len),
|
|
"checkpoint file must parse as a well-formed entity-bearing payload "
|
|
"through the trailing config-bytes section");
|
|
|
|
TEST_ASSERT_EQUAL_size_t_MESSAGE(sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1,
|
|
section_len,
|
|
"config-bytes section length must equal the source config file's "
|
|
"exact byte length");
|
|
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(SNAPENT_CONFIG_TEST_PAYLOAD, section_bytes,
|
|
section_len,
|
|
"config-bytes section content must be byte-for-byte identical to "
|
|
"the currently active configuration on disk (Requirement 2.1) -- "
|
|
"an exact match trivially implies the content hash also matches");
|
|
|
|
free(section_bytes);
|
|
snapent_teardown(f);
|
|
printf("test_snapent_checkpoint_config_bytes_section_matches_source_file: PASS\n");
|
|
}
|
|
|
|
/* Requirement 2.4's "no config yet" edge: a live cluster (real myconf wired,
|
|
* unlike every other test in this file which leaves f->c->myconf NULL) that
|
|
* has never received a config push must still produce a valid checkpoint --
|
|
* a zero-length config-bytes section, not a failed checkpoint. Mirrors
|
|
* test_snapent_checkpoint_no_hosts_yields_empty_host_section's "absence is
|
|
* not failure" shape, one level up (persist.c's read_active_config_bytes
|
|
* -ENOENT path). */
|
|
static void test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config(void)
|
|
{
|
|
snapent_fixture_t* f = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
snapent_wire_myconf(f); /* real myconf, but conf_save_config never called */
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, persist_checkpoint(&f->c->persist),
|
|
"a checkpoint on a live cluster that has not yet received its first "
|
|
"config push must still succeed -- absence of local config bytes is "
|
|
"a benign early-boot state, never a checkpoint failure");
|
|
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
uint8_t* section_bytes = NULL;
|
|
size_t section_len = 0;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
snapent_read_checkpoint_config_bytes(path, §ion_bytes, §ion_len));
|
|
TEST_ASSERT_EQUAL_size_t_MESSAGE(0, section_len,
|
|
"no local config bytes exist yet -- the section must be explicitly "
|
|
"zero-length, not omitted or a phantom non-empty blob");
|
|
TEST_ASSERT_NULL(section_bytes);
|
|
|
|
snapent_teardown(f);
|
|
printf("test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config: PASS\n");
|
|
}
|
|
|
|
/* Requirement 5.1 determinism: two independent checkpoints taken with the
|
|
* SAME active configuration unchanged on disk must produce byte-identical
|
|
* config-bytes sections -- a pure function of current on-disk content, no
|
|
* timestamp/ordering dependence.
|
|
*
|
|
* NOTE on why this does NOT also assert the two checkpoints land at
|
|
* different LSNs/paths: this fixture is a bare persist_t with no live commit
|
|
* engine (snapent_make's own doc comment -- "Deliberately OMITTED ...
|
|
* commit engine"), so appending via persist_wal_async never advances
|
|
* committed_lsn, and checkpoint_lsn (persist_checkpoint's
|
|
* last_applied_lsn snapshot) never moves past its initial value here --
|
|
* both checkpoints legitimately land at the SAME path. That is fine for
|
|
* what THIS test checks: write_snapshot's config-bytes-writing logic still
|
|
* runs twice, as two genuinely separate persist_checkpoint calls (the
|
|
* second's open/write/rename fully redoes the first's, it does not skip or
|
|
* cache anything), which is exactly the "independent materialization"
|
|
* property Req 5.1 requires -- proving it does not depend on the path
|
|
* differing. */
|
|
static void test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints(void)
|
|
{
|
|
snapent_fixture_t* f = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
snapent_wire_myconf(f);
|
|
snapent_save_test_config(f);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist));
|
|
char path1[4096];
|
|
snprintf(path1, sizeof(path1), "%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
uint8_t* bytes1 = NULL; size_t len1 = 0;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
snapent_read_checkpoint_config_bytes(path1, &bytes1, &len1));
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist));
|
|
char path2[4096];
|
|
snprintf(path2, sizeof(path2), "%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, f->c->persist.checkpoint_lsn);
|
|
|
|
uint8_t* bytes2 = NULL; size_t len2 = 0;
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
snapent_read_checkpoint_config_bytes(path2, &bytes2, &len2));
|
|
|
|
TEST_ASSERT_EQUAL_size_t_MESSAGE(len1, len2,
|
|
"independent materializations at the same unchanged configuration "
|
|
"must yield equal-length config-bytes sections");
|
|
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(bytes1, bytes2, len1,
|
|
"independent materializations at the same unchanged configuration "
|
|
"must yield byte-identical config-bytes sections (Requirement 5.1)");
|
|
TEST_ASSERT_EQUAL_size_t_MESSAGE(sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1,
|
|
len1,
|
|
"sanity: both materializations must actually carry the real config "
|
|
"content, not two empty sections trivially matching each other");
|
|
|
|
free(bytes1);
|
|
free(bytes2);
|
|
snapent_teardown(f);
|
|
printf("test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints: PASS\n");
|
|
}
|
|
|
|
/* ───────────────────────────────────────────────────────────────────────
|
|
* ha-entity-snapshots Task 4.2 (Req 3.1-3.3): rotation-guard RED test.
|
|
*
|
|
* A checkpoint file whose position matches an in-flight peer transfer
|
|
* (task 4.1's snapshot_inflight_lsn[]) must survive rotate_snapshots even
|
|
* when it would otherwise fall outside snap_keep_n. RED against today's
|
|
* rotate_snapshots, which has no awareness of in-flight transfers at all.
|
|
* ─────────────────────────────────────────────────────────────────────── */
|
|
static void test_snapent_rotation_preserves_in_flight_checkpoint(void)
|
|
{
|
|
snapent_fixture_t* f = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(f);
|
|
|
|
/* Force aggressive rotation so the protected file would otherwise be
|
|
* deleted after just one more checkpoint cycle. */
|
|
f->c->persist.config.snap_keep_n = 1;
|
|
|
|
/* Round 1: produce the checkpoint file we'll mark as in-flight. */
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
persist_wal_async(&f->c->persist, WAL_JOB_UPDATE, "a", 1));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist));
|
|
uint64_t protected_lsn = f->c->persist.checkpoint_lsn;
|
|
|
|
char protected_path[4096];
|
|
snprintf(protected_path, sizeof(protected_path),
|
|
"%s/snap-%016" PRIx64 ".capnp",
|
|
f->c->persist.snap_path, protected_lsn);
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, access(protected_path, F_OK),
|
|
"the checkpoint file must exist right after it is written");
|
|
|
|
/* Mark this checkpoint's position as referenced by an in-flight peer
|
|
* transfer -- the same field/lifecycle task 4.1 wired in ha.c. */
|
|
atomic_store_explicit(&f->c->ha.snapshot_inflight_lsn[0], protected_lsn,
|
|
memory_order_release);
|
|
|
|
/* Rounds 2-3: with snap_keep_n=1, each further checkpoint would normally
|
|
* rotate out every older file, including the one marked in-flight. */
|
|
for (int i = 0; i < 2; i++) {
|
|
TEST_ASSERT_EQUAL_INT(0,
|
|
persist_wal_async(&f->c->persist, WAL_JOB_UPDATE, "b", 1));
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist));
|
|
}
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, access(protected_path, F_OK),
|
|
"Req 3.1-3.3: a checkpoint file referenced by an in-flight peer "
|
|
"transfer must survive rotate_snapshots even when snap_keep_n "
|
|
"would otherwise have deleted it -- rotate_snapshots does not yet "
|
|
"consult snapshot_inflight_lsn[] (task 4.3 not yet implemented)");
|
|
|
|
atomic_store_explicit(&f->c->ha.snapshot_inflight_lsn[0], 0,
|
|
memory_order_release);
|
|
snapent_teardown(f);
|
|
printf("test_snapent_rotation_preserves_in_flight_checkpoint: PASS\n");
|
|
}
|
|
|
|
/* ───────────────────────────────────────────────────────────────────────
|
|
* ha-entity-snapshots Task 5.1 (Req 2.1, 2.2, 2.3, 2.4, 5.1, 5.2):
|
|
* restore-before-advance, failure-safety, and legacy-format RED tests.
|
|
*
|
|
* Unlike every test above (which deliberately parses write_snapshot's raw
|
|
* bytes to stay clear of load_snapshot's not-yet-implemented restore
|
|
* behavior — task 2.2 remediation note), these three tests call
|
|
* persist_load_snapshot DIRECTLY: it is the exact function under test for
|
|
* this task (design.md "load_snapshot (extended)").
|
|
*
|
|
* They are written against persist_load_snapshot's NEW 4-argument
|
|
* signature (with the out_entities_restored parameter) even though
|
|
* load_snapshot's internal restore behavior is still today's
|
|
* watermark-only implementation — see persist.c's persist_load_snapshot
|
|
* wrapper comment for exactly why that stopgap wrapper makes all three of
|
|
* these genuinely fail (RED) for the right reason, not a compile error:
|
|
* 1. entities are never actually restored into jobmgr/qmgr;
|
|
* 2. a corrupted entity frame is never inspected, so failure is never
|
|
* detected and the position is wrongly advanced anyway;
|
|
* 3. a legacy (position-only) file is falsely reported as fully
|
|
* restored (entities_restored=true) instead of false — the exact
|
|
* Requirement 2.4 hazard this feature exists to close.
|
|
* ─────────────────────────────────────────────────────────────────────── */
|
|
|
|
/* A status value outside every value in g_snapent_queue_statuses[] (which
|
|
* only ever sets bits 0x00/0x01/0x04) -- so a post-restore match against the
|
|
* checkpoint's real status can only be explained by a genuine
|
|
* restore_queue_state call, never by coincidental pre-existing state. */
|
|
#define SNAPENT_PRE_RESTORE_QUEUE_STATUS 0xDEAD0000u
|
|
|
|
static void snapent_force_queue_status(cluster_t* c, const char* name,
|
|
uint32_t status)
|
|
{
|
|
queue_t* q = snapent_find_queue(c, name);
|
|
TEST_ASSERT_NOT_NULL(q);
|
|
lock(q, RTTI_LOCK_WRITE);
|
|
q->queue.status = status;
|
|
unlock(q);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 7 (task 5.1, RED): a checkpoint with N jobs + M queues, loaded via */
|
|
/* persist_load_snapshot into a SEPARATE destination member, must restore */
|
|
/* every entity into the destination's live jobmgr/qmgr, report */
|
|
/* entities_restored=true, and only then reflect the checkpoint's position */
|
|
/* (Requirements 2.1, 2.2). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_load_snapshot_restores_entities_before_advancing_position(void)
|
|
{
|
|
/* Un-IGNORE'd by config-upgrade-snapshot task 4: load_snapshot_entities'
|
|
* queue decode is now migrated to the QueueAdminState capnp layout
|
|
* write_snapshot has written since task 2.2, so this real
|
|
* persist_checkpoint + persist_load_snapshot round trip (previously
|
|
* blocked -- see git history for the prior TEST_IGNORE_MESSAGE) now
|
|
* passes for real. */
|
|
|
|
const int n_jobs = 3;
|
|
const int m_queues = 2;
|
|
|
|
/* Source: produces the entity-bearing checkpoint file (a real, already-
|
|
* working write_snapshot, tasks 2.2/3.2). */
|
|
snapent_fixture_t* src = snapent_make(n_jobs, m_queues);
|
|
TEST_ASSERT_NOT_NULL(src);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist));
|
|
|
|
uint64_t expected_lsn = src->c->persist.checkpoint_lsn;
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp",
|
|
src->c->persist.snap_path, expected_lsn);
|
|
|
|
/* Destination: models a rejoining/far-behind member. jobmgr starts
|
|
* EMPTY -- jobs are inserted FRESH by restore_job (job.c:96), never
|
|
* pre-provisioned (unlike queues). qmgr has the SAME queue names
|
|
* pre-provisioned (restore_queue_state, queue.c:208, returns -ENOENT
|
|
* for a queue that doesn't already exist -- queues are config-owned,
|
|
* per design.md), but seeded with a status that deliberately does NOT
|
|
* match the checkpoint's real value, so a post-load match can only be
|
|
* explained by a genuine restore_queue_state call. */
|
|
snapent_fixture_t* dst = snapent_make(0, m_queues);
|
|
TEST_ASSERT_NOT_NULL(dst);
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
snapent_force_queue_status(dst->c, name, SNAPENT_PRE_RESTORE_QUEUE_STATUS);
|
|
}
|
|
|
|
/* Sanity: destination truly starts without any of the source's jobs. */
|
|
for (int i = 0; i < n_jobs; i++)
|
|
TEST_ASSERT_NULL(snapent_find_job(dst->c, snapent_jobid(i)));
|
|
|
|
bool entities_restored = false;
|
|
int rc = persist_load_snapshot(&dst->c->persist, path, expected_lsn,
|
|
&entities_restored);
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
|
|
"loading a well-formed entity-bearing checkpoint must succeed");
|
|
TEST_ASSERT_TRUE_MESSAGE(entities_restored,
|
|
"a checkpoint carrying job/queue entities must report "
|
|
"entities_restored=true (Requirement 2.1, 2.2)");
|
|
|
|
/* Requirement 2.1/2.2: every job restored into the live jobmgr,
|
|
* field-identical to what was checkpointed. */
|
|
for (int i = 0; i < n_jobs; i++) {
|
|
char expect_queue[48], expect_user[48], expect_cmdline[96];
|
|
snapent_job_queue_name(i, expect_queue, sizeof(expect_queue));
|
|
snapent_job_user(i, expect_user, sizeof(expect_user));
|
|
snapent_job_cmdline(i, expect_cmdline, sizeof(expect_cmdline));
|
|
|
|
job_t* j = snapent_find_job(dst->c, snapent_jobid(i));
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(j,
|
|
"every job in the checkpoint must be restored into the "
|
|
"member's live jobmgr before the position advances");
|
|
TEST_ASSERT_EQUAL_UINT64(snapent_jobid(i), j->job.jobid);
|
|
TEST_ASSERT_EQUAL_UINT32(snapent_job_state(i), j->job.state);
|
|
TEST_ASSERT_NOT_NULL(j->job.submit);
|
|
TEST_ASSERT_EQUAL_STRING(expect_queue, j->job.submit->queue);
|
|
TEST_ASSERT_EQUAL_STRING(expect_user, j->job.submit->user);
|
|
TEST_ASSERT_NOT_NULL(j->job.submit->start_info);
|
|
TEST_ASSERT_EQUAL_STRING(expect_cmdline,
|
|
j->job.submit->start_info->cmdline);
|
|
}
|
|
|
|
/* Requirement 1.1/2.1/2.2: every queue's admin status restored --
|
|
* matches the checkpoint's real value, not the pre-restore sentinel. */
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
|
|
queue_t* q = snapent_find_queue(dst->c, name);
|
|
TEST_ASSERT_NOT_NULL(q);
|
|
TEST_ASSERT_EQUAL_UINT32_MESSAGE(snapent_queue_status(i), q->queue.status,
|
|
"restore_queue_state must have been called with the "
|
|
"checkpoint's real status, not left at the pre-restore sentinel");
|
|
}
|
|
|
|
/* Requirement 2.1/2.2: the position reflecting "caught up" must hold
|
|
* TOGETHER with the entities actually being present -- both checked
|
|
* above must be simultaneously true of the end state. */
|
|
TEST_ASSERT_EQUAL_UINT64(expected_lsn, dst->c->persist.checkpoint_lsn);
|
|
TEST_ASSERT_EQUAL_UINT64(expected_lsn, dst->c->persist.current_lsn);
|
|
|
|
snapent_teardown(dst);
|
|
snapent_teardown(src);
|
|
printf("test_snapent_load_snapshot_restores_entities_before_advancing_position: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test (config-upgrade-snapshot task 4, Req 1.10): a checkpoint carrying */
|
|
/* BOTH queue and host admin-state content, loaded via a REAL */
|
|
/* persist_checkpoint + persist_load_snapshot round trip, must restore */
|
|
/* every queue AND every host correctly -- regardless of restore order */
|
|
/* between the two entity types (design.md "Referential integrity" / */
|
|
/* Design Invariant 2: re-homing decisions are baked in at materialization */
|
|
/* time, restore never re-derives them, so each entity type's restore is */
|
|
/* independent of the other's). Exercises BOTH of restore_host_state's */
|
|
/* branches (worker.c, task 3) through the real decode-and-dispatch path */
|
|
/* this task adds: */
|
|
/* - miss: a host with no local record on the destination must come */
|
|
/* back as a valid create-if-missing placeholder carrying only */
|
|
/* the restored admin bits (Requirement 1.10, 1.11). */
|
|
/* - hit: a host the destination already knows about must have its */
|
|
/* admin bits OR'd in, with its pre-existing dynamic/attrs */
|
|
/* state left completely untouched (Requirement 1.7). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
static void test_snapent_load_snapshot_restores_hosts_and_queues_end_to_end(void)
|
|
{
|
|
const int m_queues = 3;
|
|
const int n_hosts = 4; /* indices 0,1 pre-exist on dst (hit); 2,3 miss */
|
|
|
|
/* Source: produces the entity-bearing checkpoint (queues + hosts, both
|
|
* carrying real admin content plus, for hosts, dynamic bits that must
|
|
* NOT survive into the frame -- Requirement 1.7, already covered by
|
|
* collect_host_frames' own tests; re-seeded here only as realistic
|
|
* source state, not re-asserted). */
|
|
snapent_fixture_t* src = snapent_make(0, m_queues);
|
|
TEST_ASSERT_NOT_NULL(src);
|
|
snapent_seed_hosts(src->c, n_hosts);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist));
|
|
|
|
uint64_t expected_lsn = src->c->persist.checkpoint_lsn;
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp",
|
|
src->c->persist.snap_path, expected_lsn);
|
|
|
|
/* Destination: queues pre-provisioned with a sentinel status (queues are
|
|
* config-provisioned -- restore_queue_state is update-only), mirroring
|
|
* the existing job/queue round-trip test above. Only hosts 0 and 1 are
|
|
* pre-seeded (the "hit" case); hosts 2 and 3 have no local record at
|
|
* all (the "miss"/placeholder case) -- exactly the "member that hasn't
|
|
* locally replayed this host's join record yet" scenario Requirement
|
|
* 1.10 exists for. */
|
|
snapent_fixture_t* dst = snapent_make(0, m_queues);
|
|
TEST_ASSERT_NOT_NULL(dst);
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
snapent_force_queue_status(dst->c, name, SNAPENT_PRE_RESTORE_QUEUE_STATUS);
|
|
}
|
|
|
|
/* Pre-existing dynamic bit that must survive restore untouched (Req
|
|
* 1.7's flip side: restore must never CLEAR a real worker's existing
|
|
* non-admin state, only OR admin bits into it). XCE_WORKER_BUSY is the
|
|
* same dynamic bit SNAPENT_HOST_DYNAMIC_BITS already exercises above. */
|
|
const uint32_t hit_pre_existing_dynamic_bit = XCE_WORKER_BUSY;
|
|
for (int i = 0; i < 2; 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;
|
|
/* Deliberately NOT the checkpoint's real admin status -- a
|
|
* post-restore match can then only be explained by a genuine
|
|
* restore_host_state OR-in, not coincidental pre-existing state. */
|
|
hdef.status = hit_pre_existing_dynamic_bit;
|
|
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(makeWorker(dst->c->workermgr, &hdef),
|
|
"destination hit-case host seeding must succeed");
|
|
}
|
|
|
|
/* Sanity: the miss-case hosts truly have no record on the destination
|
|
* yet. */
|
|
for (int i = 2; i < n_hosts; i++) {
|
|
char name[32];
|
|
snapent_host_name(i, name, sizeof(name));
|
|
TEST_ASSERT_NULL(snapent_find_worker(dst->c, name));
|
|
}
|
|
|
|
bool entities_restored = false;
|
|
int rc = persist_load_snapshot(&dst->c->persist, path, expected_lsn,
|
|
&entities_restored);
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
|
|
"loading a checkpoint carrying both queue and host admin-state "
|
|
"content must succeed");
|
|
TEST_ASSERT_TRUE_MESSAGE(entities_restored,
|
|
"a checkpoint carrying queue/host entities must report "
|
|
"entities_restored=true");
|
|
|
|
/* Every queue's admin status restored -- proves the migrated capnp
|
|
* QueueAdminState decode still dispatches correctly to
|
|
* restore_queue_state alongside the new host section. */
|
|
for (int i = 0; i < m_queues; i++) {
|
|
char name[32];
|
|
snapent_queue_name(i, name, sizeof(name));
|
|
|
|
queue_t* q = snapent_find_queue(dst->c, name);
|
|
TEST_ASSERT_NOT_NULL(q);
|
|
TEST_ASSERT_EQUAL_UINT32_MESSAGE(snapent_queue_status(i), q->queue.status,
|
|
"queue restore must be unaffected by the new host section "
|
|
"decoding alongside it in the same checkpoint");
|
|
}
|
|
|
|
/* Hit-case hosts (0, 1): admin bits OR'd into the pre-existing worker;
|
|
* the pre-existing dynamic bit must survive untouched. */
|
|
for (int i = 0; i < 2; i++) {
|
|
char name[32];
|
|
snapent_host_name(i, name, sizeof(name));
|
|
|
|
worker_t* w = snapent_find_worker(dst->c, name);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(w,
|
|
"a host the destination already knew about must still be "
|
|
"present after restore (update, not replace)");
|
|
TEST_ASSERT_EQUAL_UINT32_MESSAGE(snapent_host_admin_status(i),
|
|
w->host.status & SNAPENT_HOST_ADMIN_STATUS_MASK,
|
|
"restore must OR the checkpoint's admin bits into the "
|
|
"pre-existing worker's status");
|
|
TEST_ASSERT_TRUE_MESSAGE(
|
|
(w->host.status & hit_pre_existing_dynamic_bit) != 0,
|
|
"restore must never clear a pre-existing worker's own "
|
|
"non-admin state (Requirement 1.7)");
|
|
}
|
|
|
|
/* Miss-case hosts (2, 3): a minimal, valid placeholder was created,
|
|
* carrying exactly the restored admin bits plus the UNAVAIL marker a
|
|
* not-yet-joined placeholder always gets -- never the source's dynamic
|
|
* bits, which were never part of the encoded frame to begin with. */
|
|
for (int i = 2; i < n_hosts; i++) {
|
|
char name[32];
|
|
snapent_host_name(i, name, sizeof(name));
|
|
|
|
worker_t* w = snapent_find_worker(dst->c, name);
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(w,
|
|
"a host with no local record must get a create-if-missing "
|
|
"placeholder (Requirement 1.10), not be silently dropped");
|
|
TEST_ASSERT_EQUAL_UINT32_MESSAGE(snapent_host_admin_status(i),
|
|
w->host.status & SNAPENT_HOST_ADMIN_STATUS_MASK,
|
|
"placeholder must carry exactly the restored admin bits");
|
|
TEST_ASSERT_TRUE_MESSAGE((w->host.status & XCE_WORKER_UNAVAIL) != 0,
|
|
"a create-if-missing placeholder must be marked "
|
|
"unjoined/dead-until-connected");
|
|
TEST_ASSERT_TRUE_MESSAGE((w->host.status & XCE_WORKER_BUSY) == 0,
|
|
"a dynamic/non-admin bit must never travel through the "
|
|
"checkpoint frame into a restored placeholder (Requirement 1.7)");
|
|
TEST_ASSERT_NOT_NULL_MESSAGE(w->host.attrs,
|
|
"a placeholder's hostattrs_t must be non-NULL so a later real "
|
|
"join's rejoin_worker direct dereference does not crash "
|
|
"(Requirement 1.11)");
|
|
}
|
|
|
|
snapent_teardown(dst);
|
|
snapent_teardown(src);
|
|
printf("test_snapent_load_snapshot_restores_hosts_and_queues_end_to_end: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 8 (task 5.1, RED): a deliberately corrupted entity frame must fail */
|
|
/* the load and leave current_lsn/checkpoint_lsn COMPLETELY unchanged -- */
|
|
/* not partially advanced, not advanced-then-reverted (Requirement 2.3). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/* Far beyond any small test checkpoint's actual remaining bytes --
|
|
* guarantees any correct decode implementation hits a short read / bounds
|
|
* failure on this frame, regardless of its exact internal read strategy. */
|
|
#define SNAPENT_CORRUPT_JOB_PAYLOAD_LEN (10u * 1024u * 1024u)
|
|
|
|
/* Byte offset of the FIRST job frame's payload_len field (design.md
|
|
* "Checkpoint File Layout (Physical)"): legacy header, then format_version
|
|
* (uint32_t), then job_count (uint32_t), then the first frame's own
|
|
* payload_len (uint32_t) begins right after. */
|
|
#define SNAPENT_FIRST_JOB_PAYLOAD_LEN_OFFSET \
|
|
(SNAPENT_LEGACY_HEADER_BYTES + (long)sizeof(uint32_t) /* format_version */ \
|
|
+ (long)sizeof(uint32_t) /* job_count */)
|
|
|
|
static void snapent_corrupt_first_job_payload_len(const char* path)
|
|
{
|
|
int fd = open(path, O_RDWR);
|
|
TEST_ASSERT_TRUE(fd >= 0);
|
|
|
|
uint32_t bogus = SNAPENT_CORRUPT_JOB_PAYLOAD_LEN;
|
|
ssize_t w = pwrite(fd, &bogus, sizeof(bogus),
|
|
(off_t)SNAPENT_FIRST_JOB_PAYLOAD_LEN_OFFSET);
|
|
TEST_ASSERT_EQUAL_INT((ssize_t)sizeof(bogus), w);
|
|
close(fd);
|
|
}
|
|
|
|
static void test_snapent_load_snapshot_corrupted_entity_leaves_position_unchanged(void)
|
|
{
|
|
const int n_jobs = 2;
|
|
|
|
snapent_fixture_t* src = snapent_make(n_jobs, 0);
|
|
TEST_ASSERT_NOT_NULL(src);
|
|
TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&src->c->persist));
|
|
|
|
uint64_t checkpoint_lsn = src->c->persist.checkpoint_lsn;
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp",
|
|
src->c->persist.snap_path, checkpoint_lsn);
|
|
|
|
snapent_corrupt_first_job_payload_len(path);
|
|
|
|
snapent_fixture_t* dst = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(dst);
|
|
|
|
/* Sentinel pre-call position -- distinguishable from both 0 and the
|
|
* checkpoint's real position, so "unchanged" is a meaningful assertion
|
|
* rather than a 0==0 tautology. */
|
|
const uint64_t sentinel_current = 4242;
|
|
const uint64_t sentinel_checkpoint = 4141;
|
|
dst->c->persist.current_lsn = sentinel_current;
|
|
dst->c->persist.checkpoint_lsn = sentinel_checkpoint;
|
|
|
|
bool entities_restored = true; /* canary -- must not be trusted on rc!=0 */
|
|
int rc = persist_load_snapshot(&dst->c->persist, path, checkpoint_lsn,
|
|
&entities_restored);
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(rc != 0,
|
|
"a checkpoint with a corrupted entity frame must fail to load, not "
|
|
"silently succeed (Requirement 2.3)");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(sentinel_current, dst->c->persist.current_lsn,
|
|
"a failed restore must leave current_lsn COMPLETELY unchanged -- "
|
|
"not partially advanced (Requirement 2.3)");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(sentinel_checkpoint, dst->c->persist.checkpoint_lsn,
|
|
"a failed restore must leave checkpoint_lsn COMPLETELY unchanged -- "
|
|
"not partially advanced (Requirement 2.3)");
|
|
|
|
/* *out_entities_restored is unspecified on rc!=0 (design.md: "only set
|
|
* when rc==0") -- deliberately NOT asserted here. */
|
|
|
|
snapent_teardown(dst);
|
|
snapent_teardown(src);
|
|
printf("test_snapent_load_snapshot_corrupted_entity_leaves_position_unchanged: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Test 9 (task 5.1, RED): a checkpoint file predating this feature */
|
|
/* (position marker only -- no format_version/job/queue sections, i.e. */
|
|
/* the file ends EXACTLY at the legacy header) must load without crashing, */
|
|
/* report entities_restored=false, and still advance current_lsn/ */
|
|
/* checkpoint_lsn to its recorded position -- the existing, PRESERVED */
|
|
/* legacy behavior (Requirements 2.4, 5.1, 5.2). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
#define SNAPENT_LEGACY_CHECKPOINT_LSN 555555ULL
|
|
|
|
/* Hand-builds a pre-ha-entity-snapshots checkpoint file: the exact 57-byte
|
|
* legacy sentinel write_snapshot has always written (magic + lsn + optional
|
|
* cfgref tail, cfgref_present=0 here) and NOTHING else -- no format_version,
|
|
* no job/queue sections. Per design.md, "a file ending exactly at the
|
|
* legacy header (no bytes remain) is unambiguously the old format." */
|
|
static void snapent_write_legacy_checkpoint(const char* path, uint64_t lsn)
|
|
{
|
|
int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600);
|
|
TEST_ASSERT_TRUE(fd >= 0);
|
|
|
|
char header[SNAPENT_LEGACY_HEADER_BYTES];
|
|
memset(header, 0, sizeof(header));
|
|
memcpy(header, "XCESNAP\0", 8);
|
|
memcpy(header + 8, &lsn, 8);
|
|
/* header[16] (cfgref_present) left 0 -- no config-ref tail. */
|
|
|
|
TEST_ASSERT_EQUAL_INT((int)sizeof(header),
|
|
(int)write(fd, header, sizeof(header)));
|
|
close(fd);
|
|
}
|
|
|
|
static void test_snapent_load_snapshot_legacy_file_reports_position_only(void)
|
|
{
|
|
snapent_fixture_t* dst = snapent_make(0, 0);
|
|
TEST_ASSERT_NOT_NULL(dst);
|
|
|
|
char path[4096];
|
|
snprintf(path, sizeof(path), "%s/snap-legacy.capnp", dst->c->persist.snap_path);
|
|
snapent_write_legacy_checkpoint(path, SNAPENT_LEGACY_CHECKPOINT_LSN);
|
|
|
|
bool entities_restored = true; /* must come back false */
|
|
int rc = persist_load_snapshot(&dst->c->persist, path,
|
|
SNAPENT_LEGACY_CHECKPOINT_LSN,
|
|
&entities_restored);
|
|
|
|
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
|
|
"a legacy (pre-feature) checkpoint must load without error -- a "
|
|
"short file is an expected shape, not corruption (Requirement 5.1, 5.2)");
|
|
TEST_ASSERT_FALSE_MESSAGE(entities_restored,
|
|
"a legacy checkpoint carries no entity content -- it must never "
|
|
"falsely claim full restoration (Requirement 2.4)");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(SNAPENT_LEGACY_CHECKPOINT_LSN,
|
|
dst->c->persist.checkpoint_lsn,
|
|
"a legacy checkpoint still records the committed position it was "
|
|
"taken at -- existing, preserved behavior");
|
|
TEST_ASSERT_EQUAL_UINT64_MESSAGE(SNAPENT_LEGACY_CHECKPOINT_LSN,
|
|
dst->c->persist.current_lsn,
|
|
"a legacy checkpoint still advances current_lsn to its recorded "
|
|
"position -- existing, preserved behavior");
|
|
|
|
snapent_teardown(dst);
|
|
printf("test_snapent_load_snapshot_legacy_file_reports_position_only: PASS\n");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Suite entry point */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
void test_persist_snapshot_entities(void)
|
|
{
|
|
UnitySetTestFile(__FILE__);
|
|
printf("\n>> entity-bearing snapshot fixture tests\n\n");
|
|
|
|
/* makeJob's deep-copy path (job_init -> makeJobsub -> makeString) needs
|
|
* the global xstring table. In the full suite some other group may have
|
|
* already initialised it (e.g. test_worker_join's unguarded call);
|
|
* standalone (-t test_persist_snapshot_entities) it is uninitialised and
|
|
* every makeString call silently returns NULL -- which job_init does not
|
|
* check, so a seeded job's submit/start_info comes back NULL without any
|
|
* visible failure at seed time. Probe instead of double-initialising
|
|
* (mirrors test_commit_barrier_reply's cbrp-probe pattern). */
|
|
if (makeString((char*)"snapent-probe") == NULL)
|
|
xstring_table_init(139);
|
|
|
|
RUN_TEST(test_snapent_fixture_seeds_jobs_and_queues);
|
|
RUN_TEST(test_snapent_checkpoint_job_content_roundtrip);
|
|
RUN_TEST(test_snapent_checkpoint_concurrent_mutation_not_torn);
|
|
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_checkpoint_config_bytes_section_matches_source_file);
|
|
RUN_TEST(test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config);
|
|
RUN_TEST(test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints);
|
|
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_restores_hosts_and_queues_end_to_end);
|
|
RUN_TEST(test_snapent_load_snapshot_corrupted_entity_leaves_position_unchanged);
|
|
RUN_TEST(test_snapent_load_snapshot_legacy_file_reports_position_only);
|
|
}
|