feat(config-upgrade-snapshot): define HostAdminState/QueueAdminState capnp structs, bump snapshot format version

Adds independently-extensible Cap'n Proto frames for the upgrade
snapshot's host/queue admin-state sections (frame-only, not reusing
Worker/QueueConf which bundle dynamic state) and bumps
SNAPSHOT_FORMAT_VERSION 1->2 so a checkpoint written under the prior
raw queue-frame layout is fail-loud rejected rather than
misinterpreted. Task 1 of 19.

Requirements: 1.9, 5.7
This commit is contained in:
Rongsong Shen 2026-07-24 08:26:49 +00:00
parent e0c5e8b7a4
commit 9e68335aee
6 changed files with 322 additions and 8 deletions

View file

@ -1628,8 +1628,17 @@ static int persist_write_checkpoint_marker(persist_t* p)
* [uint32_t job_count]
* [job_count * { uint32_t payload_len; uint8_t capnp_Job_bytes[payload_len] }]
* [uint32_t queue_count]
* [queue_count * { uint32_t name_len; char name[name_len]; uint32_t status }] */
#define SNAPSHOT_FORMAT_VERSION 1u
* [queue_count * { uint32_t name_len; char name[name_len]; uint32_t status }]
*
* config-upgrade-snapshot task 1 (Req 1.9, 5.7): bumped 1->2 to reserve the
* version for the HostAdminState/QueueAdminState capnp migration -- the
* queue-frame layout above (name_len/name/status raw bytes) is the PRIOR
* layout this version now hard-rejects; the byte-layout migration itself
* (new host section, queue frames re-encoded as capnp) lands in tasks
* 2.1/2.2. A binary built against version 2 must never misinterpret a
* checkpoint written under version 1's raw queue-frame layout (Design
* Invariant 4: fail loud, never silently reinterpret). */
#define SNAPSHOT_FORMAT_VERSION 2u
typedef struct {
uint8_t* buf;

View file

@ -3282,7 +3282,7 @@ static void test_snapshot_recv_two_chunks_assembles_and_renames(void)
* header) but is now correctly rejected as an unrecognized
* format_version. Layout (design.md "Checkpoint File Layout"): the
* 57-byte legacy header (cfgref_present=0 at byte 16, so bytes 17-56 are
* unread and left zero) + format_version=1 + job_count=0 + queue_count=0
* unread and left zero) + format_version=2 + job_count=0 + queue_count=0
* (69 bytes total), padded with zero filler out to 128 bytes to keep the
* two-64-byte-chunk shape this test exercises — the padding is never
* read (load_snapshot_entities stops right after the queue_count=0
@ -3294,7 +3294,8 @@ static void test_snapshot_recv_two_chunks_assembles_and_renames(void)
memcpy(body, "XCESNAP\0", 8);
memcpy(body + 8, &snap_lsn, 8);
/* body[16] (cfgref_present) left 0 by the memset above. */
uint32_t fake_fmt = 1u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION */
uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
* (config-upgrade-snapshot task 1 bumped 1->2) */
uint32_t fake_job_count = 0u;
uint32_t fake_queue_count = 0u;
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
@ -3376,6 +3377,107 @@ static void test_snapshot_recv_two_chunks_assembles_and_renames(void)
printf("test_snapshot_recv_two_chunks_assembles_and_renames: PASS\n");
}
/* config-upgrade-snapshot task 1 (Req 1.9, 5.7): SNAPSHOT_FORMAT_VERSION was
* bumped 1->2 to reserve the version for the HostAdminState/QueueAdminState
* capnp migration of the host/queue admin-state frames. A checkpoint written
* under the PRIOR format_version (1 -- the raw queue-frame layout this spec
* migrates off of) must be hard-rejected fail-loud by a binary built against
* the bumped SNAPSHOT_FORMAT_VERSION=2, never silently reinterpreted (Design
* Invariant 4; persist.c:2541's existing `fmt != SNAPSHOT_FORMAT_VERSION`
* check, unchanged logic, new constant). Same well-formed-except-for-
* format-version fixture shape as
* test_snapshot_recv_two_chunks_assembles_and_renames above, but the
* embedded format_version is pinned to the literal PRIOR value (1)
* regardless of whatever persist.c's SNAPSHOT_FORMAT_VERSION currently is —
* this is exactly the "old on-disk checkpoint, new binary" skew the version
* bump must reject. */
static void test_snapshot_recv_old_format_version_rejected_fail_loud(void)
{
char tmpdir[] = "/tmp/xcectest_snaprecv_oldfmt_XXXXXX";
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
char snap_dir[512];
snprintf(snap_dir, sizeof(snap_dir), "%s/snap", tmpdir);
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0700));
uint64_t snap_lsn = 200;
size_t total_size = 128;
uint8_t body[128];
memset(body, 0, sizeof(body));
memcpy(body, "XCESNAP\0", 8);
memcpy(body + 8, &snap_lsn, 8);
/* body[16] (cfgref_present) left 0 by the memset above. */
uint32_t old_fmt = 1u; /* the pre-migration raw queue-frame layout --
* deliberately NOT persist.c's current
* SNAPSHOT_FORMAT_VERSION */
uint32_t fake_job_count = 0u;
uint32_t fake_queue_count = 0u;
memcpy(body + 57, &old_fmt, sizeof(old_fmt));
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
uint8_t chunk0[64];
uint8_t chunk1[64];
memcpy(chunk0, body, 64);
memcpy(chunk1, body + 64, 64);
persist_t *p = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p);
persist_config_t pcfg = persist_config_defaults();
persist_set_workdir(p, tmpdir, "ctrl-snaprecv-oldfmt");
persist_init(p, &pcfg, NULL);
persist_start(p);
ha_state_t ha;
snap_recv_ha_init(&ha);
/* Chunk 0 — assembling snap.part never itself parses the body, so this
* must still succeed regardless of what format_version the bytes carry. */
snapshot_transfer_t c0;
memset(&c0, 0, sizeof(c0));
c0.snapshot_lsn = snap_lsn;
c0.chunk_index = 0;
c0.chunk_offset = 0;
c0.total_size = (uint64_t)total_size;
c0.is_last = 0;
int rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c0, chunk0, 64);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "assembling a chunk never parses the body");
/* Chunk 1 (isLast=true) triggers rename + persist_load_snapshot — this is
* where the format-version check fires and must fail loud. */
snapshot_transfer_t c1;
memset(&c1, 0, sizeof(c1));
c1.snapshot_lsn = snap_lsn;
c1.chunk_index = 1;
c1.chunk_offset = 64;
c1.total_size = (uint64_t)total_size;
c1.is_last = 1;
rc = ha_handle_snapshot_chunk(&ha, p, snap_dir, &c1, chunk1, 64);
TEST_ASSERT_TRUE_MESSAGE(rc != 0,
"a checkpoint encoded under the prior SNAPSHOT_FORMAT_VERSION must be "
"rejected fail-loud by a binary built against the bumped version, "
"never silently reinterpreted (Requirement 5.7)");
/* A rejected load must never advance applied_lsn (no partial/silent
* acceptance of mismatched-format bytes). */
uint64_t got_lsn = (uint64_t)atomic_load_explicit(&ha.applied_lsn,
memory_order_acquire);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(0, got_lsn,
"a rejected format-mismatched checkpoint must never advance applied_lsn");
ha_fini(&ha);
persist_stop(p);
persist_fini(p);
free(p);
char cmd2[512];
snprintf(cmd2, sizeof(cmd2), "rm -rf %s", tmpdir);
system(cmd2);
printf("test_snapshot_recv_old_format_version_rejected_fail_loud: PASS\n");
}
/* Test 6.1: a legacy (pre-ha-entity-snapshots) position-only snapshot —
* exactly the 16-byte XCESNAP magic+lsn header, no entity payload — driven
* through the real receive handler end-to-end. persist_load_snapshot's
@ -3571,7 +3673,8 @@ static void test_snapshot_recv_128kb_two_chunks(void)
memcpy(chunk0, "XCESNAP\0", 8);
memcpy(chunk0 + 8, &snap_lsn, 8);
/* chunk0[16] (cfgref_present) already 0 from calloc. */
const uint32_t fake_fmt = 1u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION */
const uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
* (config-upgrade-snapshot task 1 bumped 1->2) */
memcpy(chunk0 + 57, &fake_fmt, sizeof(fake_fmt));
/* ── Chunk 0: not last ── */
@ -3662,7 +3765,7 @@ static void test_snapshot_recv_out_of_order_chunks_assemble_correctly(void)
/* 4 chunks of 64 bytes each = 256-byte body. Must be a WELL-FORMED
* entity-bearing checkpoint (same reason as
* test_snapshot_recv_two_chunks_assembles_and_renames above): legacy
* 57-byte header (cfgref_present=0) + format_version=1 + job_count=0 +
* 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
@ -3676,7 +3779,8 @@ static void test_snapshot_recv_out_of_order_chunks_assemble_correctly(void)
memcpy(body + 8, &snap_lsn, 8);
for (int i = 16; i < 256; i++)
body[i] = (uint8_t)(i * 3 + 7); /* position-dependent, non-repeating */
uint32_t fake_fmt = 1u;
uint32_t fake_fmt = 2u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION
* (config-upgrade-snapshot task 1 bumped 1->2) */
uint32_t fake_job_count = 0u;
uint32_t fake_queue_count = 0u;
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
@ -4268,6 +4372,9 @@ void test_config_repl(void)
RUN_TEST(test_snapshot_transfer_stops_and_propagates_send_failure);
/* Task 3.5c: standby snapshot receive handler */
RUN_TEST(test_snapshot_recv_two_chunks_assembles_and_renames);
/* config-upgrade-snapshot task 1: prior SNAPSHOT_FORMAT_VERSION rejected
* fail-loud by a binary built against the bumped version */
RUN_TEST(test_snapshot_recv_old_format_version_rejected_fail_loud);
/* Task 6.1: legacy position-only file consumed end-to-end via the
* receive handler — entities_restored=false path */
RUN_TEST(test_snapshot_recv_legacy_file_advances_without_entities);

View file

@ -375,7 +375,8 @@ static void test_snapent_fixture_seeds_jobs_and_queues(void)
/* ------------------------------------------------------------------ */
#define SNAPENT_LEGACY_HEADER_BYTES (16 + 1 + 8 + 32)
#define SNAPENT_TEST_FORMAT_VERSION 1u
#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`

View file

@ -443,6 +443,25 @@ typedef struct {
uint32_t prevCrc;
} capnp_repl_envelope_t;
/* ── Upgrade snapshot admin-state frames (config-upgrade-snapshot task 1,
* Req 1.9/5.7) ───────────────────────────────────────────────────────────
* Frame-only wire types for the checkpoint's host/queue admin-state
* sections -- carry only the administratively-set (name, status) pair,
* never the full xce_worker_t/xce_queue_t (design.md Design Invariant 5: a
* snapshot frame must never inherit a live struct's dynamic fields just
* because reusing it is convenient). Neither has a corresponding "live" C
* struct, so each gets its own frame-only counterpart here, same as
* capnp_snap_hdr_t/capnp_persist_cfg_t/capnp_repl_envelope_t above. */
typedef struct {
char* name;
uint32_t status;
} capnp_host_admin_state_t;
typedef struct {
char* name;
uint32_t status;
} capnp_queue_admin_state_t;
/* ── Config replication Cap'n Proto mapped types (Phase 1) ──────────────
* C-side counterparts for the capnp encode/decode shims for config sync.
* Struct tags match the forward declarations near the top of this file. */

View file

@ -752,6 +752,30 @@ struct SnapshotHeader $C.mapname("capnp_snap_hdr_t") {
headerChecksum @8 :UInt32;
}
# ── Upgrade snapshot admin-state frames (config-upgrade-snapshot task 1,
# Req 1.9/5.7) ──────────────────────────────────────────────────────────
# Independently-extensible Cap'n Proto frames for the on-disk checkpoint's
# host/queue admin-state sections (SNAPSHOT_FORMAT_VERSION 2, persist.c).
# New admin-state fields (e.g. a lock reason or tag) get new ordinals under
# capnp's own schema-evolution rules -- no future SNAPSHOT_FORMAT_VERSION
# bump needed per field addition. Deliberately NOT mapname'd onto
# Worker/QueueConf (design.md Design Invariant 5): those live runtime
# structs bundle static attrs and dynamic state together, which a snapshot
# frame must never carry (Requirement 1.7 excludes dynamic fields). Each
# maps instead onto its own frame-only C counterpart in typemap.h, matching
# the existing capnp_snap_hdr_t/capnp_persist_cfg_t/capnp_repl_envelope_t
# convention already established below for schema-only wire concepts with
# no corresponding "live" C struct.
struct HostAdminState $C.mapname("capnp_host_admin_state_t") {
name @0: Text;
status @1: UInt32;
}
struct QueueAdminState $C.mapname("capnp_queue_admin_state_t") {
name @0: Text;
status @1: UInt32;
}
# ── HA heartbeat ────────────────────────────────────────────────────
struct HeartbeatMsg $C.mapname("heartbeat_msg_t") {
senderPriority @0: UInt32 $C.mapname("sender_priority");