feat(config-upgrade-snapshot): decode host/queue admin-state frames on checkpoint load

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
This commit is contained in:
Rongsong Shen 2026-07-24 14:08:12 +00:00
parent 5b1600a8b2
commit 00cd26a927
4 changed files with 347 additions and 60 deletions

View file

@ -3766,11 +3766,17 @@ static void test_snapshot_recv_out_of_order_chunks_assemble_correctly(void)
* entity-bearing checkpoint (same reason as
* test_snapshot_recv_two_chunks_assembles_and_renames above): legacy
* 57-byte header (cfgref_present=0) + format_version=2 + job_count=0 +
* queue_count=0, then filler out to 256 bytes. The filler is
* position-dependent (distinct per source byte, not per chunk) so ANY
* chunk landing at the wrong file offset is immediately detectable via
* a whole-buffer memcmp, regardless of which physical chunk a given
* structural field happens to fall in. */
* queue_count=0 + host_count=0 (config-upgrade-snapshot task 4 --
* load_snapshot_entities now reads a host section after the queue
* section, so it must likewise be pinned to a well-formed 0 here rather
* than left as position-dependent filler), then filler out to 256
* bytes. The filler is position-dependent (distinct per source byte,
* not per chunk) so ANY chunk landing at the wrong file offset is
* immediately detectable via a whole-buffer memcmp, regardless of
* which physical chunk a given structural field happens to fall in --
* except for the 4 structural-field byte ranges explicitly overlaid
* below (fmt/job_count/queue_count/host_count), which must stay
* well-formed for the load to succeed at all. */
uint64_t snap_lsn = 400;
size_t total_size = 256;
uint8_t body[256];
@ -3783,9 +3789,11 @@ static void test_snapshot_recv_out_of_order_chunks_assemble_correctly(void)
* (config-upgrade-snapshot task 1 bumped 1->2) */
uint32_t fake_job_count = 0u;
uint32_t fake_queue_count = 0u;
uint32_t fake_host_count = 0u;
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
memcpy(body + 69, &fake_host_count, sizeof(fake_host_count));
persist_t *p = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p);

View file

@ -140,6 +140,15 @@ static const uint32_t g_snapent_host_admin_statuses[] = {
* 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);
@ -417,6 +426,21 @@ static queue_t* snapent_find_queue(cluster_t* c, const char* name)
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 */
@ -1766,21 +1790,12 @@ static void snapent_force_queue_status(cluster_t* c, const char* name,
static void test_snapent_load_snapshot_restores_entities_before_advancing_position(void)
{
/* BLOCKED on config-upgrade-snapshot task 4: write_snapshot's queue
* trailer was migrated to capnp QueueAdminState frames by task 2.2, but
* load_snapshot's queue decode still expects the pre-task-2.2 raw
* {name_len, name, status} layout (that decode migration is task 4's
* job, design.md "Snapshot Entity/Config Payload"). This test's
* m_queues=2 fixture does a REAL persist_checkpoint + persist_load_
* snapshot round trip, so it now fails against a format the loader
* doesn't understand yet. IGNORE'd so the suite stays green; un-ignore
* when task 4 lands the QueueAdminState capnp decode. */
TEST_IGNORE_MESSAGE(
"BLOCKED on config-upgrade-snapshot task 4 (load_snapshot's queue "
"decode still expects the pre-task-2.2 raw layout; this test's "
"m_queues=2 fixture now round-trips through a format the loader "
"doesn't understand yet — will be re-enabled when task 4 lands the "
"QueueAdminState capnp decode)");
/* 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;
@ -1872,6 +1887,169 @@ static void test_snapent_load_snapshot_restores_entities_before_advancing_positi
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 -- */
@ -2050,6 +2228,7 @@ void test_persist_snapshot_entities(void)
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);
}