feat(ha-entity-snapshots): restore-before-advance load_snapshot implementation (task 5.2)

Replaces the naive task-5.1 stopgap with the real restore contract:
detects legacy vs entity-bearing checkpoints by remaining file size;
restores every job then every queue status via restore_job/
restore_queue_state, in file order, with bounds-checked length-prefixed
reads; only after every entity restores successfully does
current_lsn/checkpoint_lsn commit to the checkpoint's position. Every
failure path (bounds check, decode, restore error, unrecognized
format_version) returns before that single commit point -- position is
provably untouched on failure, never partially advanced.

Also fixes the pre-existing restore_job decode leak (job.c): job_init
deep-copies submit (never nulled) and MOVE_PTRs allocs/pidinfo/
pend_reasons (nulled on the one path that runs) -- free_Job(&j) is safe
unconditionally on every reachable return path (EEXIST early-return,
create_job success, create_job failure), verified against the exact
generated free_*_ptr null-check guards.

Two pre-existing test_config_repl.c wire-protocol tests needed their
synthetic garbage snapshot bodies updated to a well-formed empty-entity
tail, since load_snapshot now genuinely parses past the legacy header
(previously ignored); their actual assertions (chunk-reassembly
correctness) are unchanged.

This is the exact function/contract that caused task 2.2's rejection
(position advancing before restore validated). Independent review
traced the memory-safety fix through the generated capnp code field-by-
field, confirmed the test_config_repl.c change doesn't weaken any
assertion, and confirmed restore_queue_state's ENOENT-aborts-the-whole-
checkpoint behavior is faithful to design.md's unconditional contract
(flagged as a watch-item for task 7.1's soak, not a defect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYFbrgyDHbvNYcxApKhrX
This commit is contained in:
Rongsong Shen 2026-07-01 08:30:10 +00:00
parent a8e300d96a
commit 9fdb6778d4
4 changed files with 297 additions and 56 deletions

View file

@ -118,12 +118,37 @@ int restore_job(cluster_t* c, uint64_t jobid_unused,
lock(c->jobmgr, RTTI_LOCK_READ);
int found = key_find(c->jobmgr, (void*)j.jobid, (void**)&existing);
unlock(c->jobmgr);
if (found > 0 && existing != NULL)
if (found > 0 && existing != NULL) {
/* j was fully decoded above (submit/allocs/pidinfo/pend_reasons all
* populated by decode_Job) and create_job() is never reached on this
* path, so ownership never moved -- every decoded pointer is still
* ours to release here (ha-entity-snapshots task 5.2: fixes the
* pre-existing decode leak on this early-return path, called out in
* tasks.md's Implementation Notes). */
free_Job(&j);
return -EEXIST;
}
job_t* job = create_job(c, &j);
int saved_rc = job ? 0 : -errno;
/* create_job() (jobj.c job_init) takes ownership of j.allocs/j.pidinfo/
* j.pend_reasons via MOVE_PTR (nulling them here on success) and makes
* its OWN deduplicated copies of j.submit/j.ckptinfo (makeJobsub/
* makeString) -- so j.submit, the one field decode_Job always populates
* that job_init never moves, is never released by create_job and must
* always be freed here. free_Job() null-checks each of its four fields
* before freeing (submit/allocs/pidinfo/pend_reasons), so calling it
* unconditionally is safe whether create_job succeeded (three of the
* four already nulled by the MOVE_PTR above) or failed before job_init
* ran any MOVE_PTR (none nulled, all four correctly released) -- never a
* double free either way (ha-entity-snapshots task 5.2, pre-existing
* leak noted in tasks.md's Implementation Notes). */
free_Job(&j);
job_t* job = create_job(c, &j);
if (!job)
return -errno;
return saved_rc;
/* ha-convergence-recovery (3.3): tag the restored job with its submit
* entry's LSN so the egress watermark predicate can decide its commit
* status (job_submit_committed). On a promoted primary, replayed entries