feat(scheduler-core): implement snapshot save and load (task 8.1)

Adds xce_sched_core_save_snapshot()/xce_sched_core_load_snapshot(),
split across snapshot.c/snapshot_wire.c/snapshot_load.c (800-line cap)
along real save/load/shared-helper boundaries. Serializes the full
core state (config, jobs, queues, workers, resources, resvals, users,
projects, prng_seed, internal_state) into/out of sched_snapshot_data_t
(task 7.3's committed C mirror of the capnp SnapshotData struct) as
plain in-memory structures -- no wire encoding here, that's a later
task. After load, rebuilds the picker's private state by re-invoking
its state-change hooks (queue updates, then job pushes) against the
restored core state, matching xce_core_push_events()'s own hook-firing
order; picker-private state itself is never stored in the snapshot.

Adds a new prng_seed field to xce_sched_core_t -- requirements.md/
design.md both presuppose it but no prior task added it. Seeded to 0,
no PRNG consumer wired yet (deliberately out of scope).

Cross-core behavioral-equivalence proven directly: two independent
cores, each with its own registered picker, save on one and load into
a freshly-initialized other, both driven through an identical real
session, decision sequences asserted identical in order.

Found and fixed two real bugs along the way: a missing prng_seed
restore in this task's own new code, and a genuine guard bug in
already-committed core.c (sched_get_user()/sched_get_project()
treating key_find()'s expected -ENOENT as fatal, silently breaking
get-or-create for any new name) that also affects the live production
path (make_sched_job(), JOBS_PUSH) -- a narrowly-scoped two-line fix,
independently traced to the primitive level and confirmed correct by
review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dw5WnN3DXStaEaPYfACU1
This commit is contained in:
Rongsong Shen 2026-08-05 03:57:10 +00:00
parent 7704710985
commit 1a32e63a86
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
10 changed files with 2383 additions and 4 deletions

View file

@ -164,6 +164,30 @@ struct xce_sched_core_s {
*/
uint64_t session_start;
uint64_t session_duration_ns;
/*
* PRNG seed for deterministic tie-breaking (Req 5 AC 7 / task 8.1). This
* field did not exist before task 8.1 -- requirements.md's Req 5 AC 7
* explicitly names `core->prng_seed` ("Any required randomness ... shall
* use a PRNG seeded by the driver at init time") and design.md's
* eventual `xce_sched_core_init(core, decision_cb, driver_ctx,
* prng_seed)` signature (see this header's own doc comment above
* xce_sched_core_init()) both presuppose it, but no prior task added it
* or wired a PRNG consumer -- confirmed by grepping this entire spec's
* source tree for "prng"/"seed" before this task. Adding ONLY the field
* here, seeded to a deterministic constant (0) by
* xce_sched_core_init(), keeps this task's scope to "the field exists
* and round-trips through save_snapshot()/load_snapshot()"
* (Req 5 AC 21's "PRNG state" checklist row) -- NOT changing
* xce_sched_core_init()'s signature (still deliberately narrower per
* this header's doc comment below) and NOT wiring any actual PRNG
* consumer, since nothing in the codebase performs tie-breaking
* randomness yet (builtin_fcfs's ordering is fully deterministic via
* ordq comparators, no PRNG call site exists to seed). A future task
* that adds real PRNG-based tie-breaking is expected to add its own
* setter, the same pattern xce_core_set_decision_cb() already
* establishes for driver_ctx/on_decision.
*/
uint64_t prng_seed;
};
/*
@ -457,4 +481,70 @@ extern uint64_t xce_core_now(xce_sched_core_t*);
extern void xce_core_set_active_for_logging(xce_sched_core_t*);
extern int xce_core_register_tlog_format(void);
/*
* SCHED_SNAPSHOT_INTERNAL_VERSION - the current format tag for
* `sched_snapshot_data_t.internal_state`'s opaque byte layout (task 8.1).
* Declared here, rather than privately in snapshot.c, because BOTH
* xce_sched_core_save_snapshot() (snapshot.c, which writes it) and
* xce_sched_core_load_snapshot() (snapshot_load.c, which version-checks
* it) need the identical value -- a shared header is the single-source-of-
* truth home for a constant two separate translation units must agree on
* bit-for-bit; duplicating it as two independent #defines would risk
* exactly the kind of silent drift this task's own documentation
* (snapshot.c's file header, "internal_state contents") goes out of its
* way to avoid elsewhere. This is an internal implementation detail of the
* two functions below, not part of any wire/capnp contract -- callers of
* either function never need to read it directly.
*/
#define SCHED_SNAPSHOT_INTERNAL_VERSION (1u)
/*
* xce_sched_core_save_snapshot / xce_sched_core_load_snapshot - Req 5 AC 21
* / Req 6 AC 5 (scheduler-core spec, task 8.1; design.md "SchedulerCore"
* Service Interface). Serialize the full core state into (out of) a
* `sched_snapshot_data_t` (typemap.h, the C mirror of task 7.3's capnp
* `SnapshotData` struct) -- populate/consume it from `xce_sched_core_t`'s
* live in-memory state directly; NO capnp encode/decode happens here (that
* is a later, wire-format task's job). Implemented across three files --
* snapshot.c (the save direction + the full shared design-decision
* documentation), snapshot_wire.c (small helpers + save-direction wire
* builders), and snapshot_load.c (the load direction + picker-rebuild) --
* split for this project's 800-line-per-file convention; see snapshot.c's
* own file header comment, "Three-file split", for the full rationale.
*
* Both are only valid between scheduling sessions, matching every other
* core entry point's precondition (xce_core_push_events()'s own doc
* comment, above): return -EBUSY, with ZERO state touched, if
* `core->session_active != 0` -- checked BEFORE any mutation, so a rejected
* call is a true no-op (same pattern xce_core_begin_session()/
* xce_core_push_events() already establish). Both return -EINVAL for a
* NULL core or a NULL out/in pointer.
*
* xce_sched_core_save_snapshot() populates `*out` from `*core`'s current
* state (jobs/queues/workers/resources/resvals/users/projects/config/
* prng_seed/internal_state); see snapshot.c's own file header comment for
* the full field-by-field mapping and the documented scope decisions
* (job-level priority and in-flight allocations are NOT captured -- see
* that comment for why).
*
* xce_sched_core_load_snapshot() restores `*core` (which must be freshly
* initialized via xce_sched_core_init() -- loading into a core that
* already holds state is not this task's scope, and is not what the
* driver-side callers design.md documents -- record start, periodic
* checkpoint, simulation startup -- ever do) from `*in`, THEN rebuilds the
* resolved picker's private state by walking the just-restored core state
* and re-invoking the picker's state-change hooks in the same order
* xce_core_push_events() already establishes for state-change hooks:
* on_queue_update for every restored queue, THEN on_job_push for every
* restored job. Picker-private state is deliberately never stored in the
* snapshot itself (requirements.md Req 5 AC 21's own "Picker state is NOT
* stored in snapshot" rule) -- this rebuild is what makes post-load state
* indistinguishable from state built up via normal xce_core_push_events()
* calls, which is the whole point of the Observable this task proves.
*/
extern int xce_sched_core_save_snapshot(xce_sched_core_t*,
sched_snapshot_data_t*);
extern int xce_sched_core_load_snapshot(xce_sched_core_t*,
const sched_snapshot_data_t*);
#endif