myxce/.kiro/specs/ha-entity-snapshots/research.md
Rongsong Shen 10f9bbfd9d feat(ha-entity-snapshots): add spec + task 1.1 test fixture
Adds the ha-entity-snapshots Kiro spec (requirements, design, research,
tasks) for task 5.10a / raft-ha-core-safety: making the HA checkpoint an
actually-restorable copy of committed job/queue state instead of a
position-only sentinel, closing the residual chaotic-soak divergence gap.

Task 1.1 (foundation): reusable test fixture snapent_make/snapent_teardown
in a new test_persist_snapshot_entities.c, seeding N jobs (varying state)
and M queues (varying status) into a real persist_t+cluster_t for later
write/load/rotation-guard tests to build on. Build clean; new suite 1/1;
regression spot-checks (test_persist_hooks 19/19, test_commit_barrier_reply
22/22) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYFbrgyDHbvNYcxApKhrX
2026-07-01 04:55:55 +00:00

18 KiB

Gap Analysis: ha-entity-snapshots

Summary

This feature is substantially cheaper than it looks. Every low-level primitive it needs already exists in the codebase for an unrelated purpose (WAL replay reconstruction) or is auto-generated by the existing Cap'n Proto schema. The work is almost entirely wiring: enumerate live entities → serialize into the snapshot file → on load, deserialize and call the existing restore functions → extend the existing trim-floor guard. No new capnp schema struct is strictly required (a length-prefixed sequence of existing per-entity blobs works), though a dedicated Snapshot struct would be cleaner. The main open design question is write-side concurrency: the "proper" mechanism (zarray copy-on-write) exists and is tested, but was never wired into the job/queue backing store (kvmgr) — a deliberate deferral ("wired in P0-2"), not a bug. This is the one real judgment call for the design phase.

Requirement-to-Asset Map

Requirement Existing Asset Gap
R1.1 include every live/retained job+queue record collect_jobs/collect_queues (dataex.c:215-330, lock READ → iter_make/iter_next → unlock); job_should_retain/worker_should_retain (persist.c:3013+) already implement the retention predicate Missing: nothing enumerates for the snapshot purpose today — write_snapshot (persist.c ~1477-1519) writes only a 57-byte header. Wiring, not new logic.
R1.1 per-entity serialization capnp_encode_job (persist.c:45), capnp_encode_worker (persist.c:68); capnp codegen for QueueConf/xce_queue_t — encode_QueueConf, write_QueueConf, new_QueueConf, new_QueueConf_list all already generated (controller.capnp.h) Missing: a capnp_encode_queue wrapper — trivial, mirrors capnp_encode_job line-for-line (~15 lines).
R1.2 internally consistent point-in-time view zarray COW (src/lib/zarray.c xzarray_freeze/xzarray_thaw, tested in test_zarray_cow.c) Constraint: COW exists but is NOT wired into kvmgr (the hash table backing c->jobmgr/c->qmgr) — kvmgr.c:297-298 explicitly stubs vfreeze/vthaw to NULL with a "wired in P0-2" comment. Consistency must come from a RTTI_LOCK_READ held across the full enumeration+encode instead (same lock collect_jobs already takes, just held longer). This is the one real design decision — see Options below.
R1.3 discard incomplete checkpoint write_snapshot's existing tmp-file + atomic rename pattern (persist.c ~1477+, same pattern as ha_handle_snapshot_chunk's snap.part → final rename, ha.c:2589-2594) None — extend the existing tmp+rename-on-success pattern to the entity payload; a partial write never reaches the final path.
R1.4 record committed position Already present (checkpoint_lsn in the existing sentinel header, persist.c) None.
R2.1/2.2 restore entities before advancing position restore_job (job.c:96, called from WAL replay persist.c:1915) and restore_queue_state (queue.c:208, called persist.c:1945) — both already exist and are proven (used every restart via apply_wal_entry) Missing: load_snapshot (persist.c:1811-1847) currently sets current_lsn/checkpoint_lsn directly (line ~1835) with no entity step. Insert a decode-and-restore_job/restore_queue_state loop before that assignment, reusing the exact WAL-replay call.
R2.3 failed reconstruction must not advance position N/A — new logic, but trivial given restore functions already return rc Missing: gate the current_lsn/checkpoint_lsn store on the restore loop's success (straightforward given R2.1/2.2's insert point).
R2.4 pre-feature checkpoint treated as insufficient N/A Missing: a format/version discriminator. Cheapest: if the file is exactly the legacy 57-byte sentinel size with no trailing entity payload, treat as position-only (do not claim completeness); a new format adds a marker/count after the existing header.
R3 retention never outruns restorability repl_trim_lsn (repl.c:650-683) + the bug-1147 guard in trim_wal_segments (persist.c:1615, floor = min(checkpoint_lsn, every active standby's acked_lsn, disconnected-slot last_disconnected_lsn)) — this is an EXISTING, working guard, confirmed by its own comment: "does NOT fix the empty/far-behind join case... requires entity-bearing snapshots (separate spec)" — i.e. this spec is the one it names. Extension, not new: add "a standby is currently mid-install of snapshot at LSN X" as another floor input into repl_trim_lsn's min_lsn computation — same shape as the existing acked_lsn/disconnected-slot terms.
R4 end-to-end catch-up correctness ha_evaluate_repl_action (ha.c:2644, unchanged by this spec) → ha_snapshot_send_dispatch/ha_snapshot_do_send (ha.c ~2788+, unchanged) → wire opcode OPCode_snapshotTransfer → handle_snapshot_transfer (cluster.c:197-229) → ha_handle_snapshot_chunk (ha.c:2529-2609): assembles snap.part, renames to final path, calls persist_load_snapshot, then sets ha->applied_lsn. Insertion point identified, not missing infra: the entity-restore work belongs entirely inside persist_load_snapshot/load_snapshot (R2's gap). Once that returns correctly, ha_handle_snapshot_chunk's existing "only advance applied_lsn after persist_load_snapshot succeeds" structure already gives R4's ordering for free.
R4.3 operator-observable catch-up completeness walcompare/waldump tooling already used this session to diagnose divergence live None new required; existing tooling already surfaces divergence. Confirm it also reports on entity-snapshot-derived state, not just WAL-visible LSNs (verify in design).
R5 safe rollout / old-format checkpoints rotate_snapshots already discriminates by filename pattern (*.capnp) Missing: the actual content-based old-vs-new format check (R2.4's gap, restated here for the read path).

Implementation Approach Options

  • Extend write_snapshot/load_snapshot (persist.c) directly: after the existing sentinel header, append a length-prefixed sequence of capnp-encoded Job and QueueConf blobs (reusing capnp_encode_job, a new trivial capnp_encode_queue, and on load decode_Job/ decode_QueueConf + restore_job/restore_queue_state).
  • Write-side consistency: hold RTTI_LOCK_READ on c->jobmgr/c->qmgr across the full enumerate+encode (mirrors collect_jobs's existing lock scope, just held for the whole snapshot instead of one collection pass). Do the actual disk write from an in-memory buffer built while holding the lock, then release the lock before fdatasync+rename — so file I/O latency does not extend the lock hold.
  • Extend repl_trim_lsn with one more floor term (in-flight snapshot-install LSN per standby).
  • ✅ Every serialization/restore primitive already exists and is proven (WAL replay uses the same restore_job/restore_queue_state) — minimal new code, maximum reuse of tested paths.
  • ✅ No new wire opcode, no new capnp schema struct strictly required.
  • ❌ Holding a read lock across a full job-table walk blocks concurrent job mutations for the checkpoint's duration — needs a bound (checkpoint interval is already configurable; snap_interval_s exists) and a measurement of walk cost at realistic job-table sizes.

Option B: Wire zarray COW into kvmgr first, then build the snapshot on top

  • Complete the deferred "P0-2" work (wire xzarray_freeze/xzarray_thaw into kvmgr), then write_snapshot freezes a shadow, walks it lock-free, and thaws — no read-lock hold during the walk.
  • ✅ Removes the lock-hold concern entirely; likely benefits other future features too.
  • ❌ Meaningfully larger and touches a shared, load-bearing data structure (kvmgr backs both job and queue tables and possibly other managers) used cluster-wide — higher blast radius and risk for a benefit (lock-free enumeration) that may not be needed if Option A's lock-hold duration proves acceptable in practice.
  • ❌ It was deliberately deferred once already; reviving it is its own scoped effort, arguably a separate spec/prerequisite rather than embedded in this one.

Option C: Hybrid — ship Option A now, leave a documented seam for Option B later

  • Build the snapshot feature entirely per Option A, but structure the enumeration behind a single seam function (e.g. persist_collect_snapshot_entities) so that if kvmgr COW is wired later, only that one function's internals change (lock+iterate → freeze+iterate+thaw), nothing else in the snapshot read/write/trim/catch-up path needs to change.
  • ✅ Ships the actual bug fix now; keeps the door open without committing to the larger refactor's risk today.
  • ❌ Slightly more upfront interface discipline than just inlining the lock+iterate call.

Effort & Risk

  • Effort: M (3-7 days). All hard primitives (encode/decode/restore) pre-exist and are proven; the work is wiring four call sites (write, load, trim, and the format-discriminator) plus tests. Design and TDD discipline (per .kiro/steering/testing.md) will take real time given this is HA/persistence-critical code, but there is no unknown technology.
  • Risk: Medium. Known patterns throughout (Option A), but this is exactly the kind of code the raft-ha-core-safety family (bug-795/828/851/1306) has shown is easy to get subtly wrong under concurrent chaos — the actual risk is in getting R2.3 (failed-restore-must-not- advance-position) and R3 (trim-floor extension) correct under a live chaotic soak, not in writing the serialization code itself. Recommend the same validation bar the recent session's fixes used: unit RED/GREEN + a chaotic multi-round soak showing committed_divergence=0.

Research Needed (carry to design phase)

  1. Snapshot file layout: a single new capnp Snapshot { jobs: List(Job); queues: List(QueueConf); ... } struct (cleaner, one capn init/write) vs. keeping the existing raw header + hand-rolled length-prefixed blob sequence (smaller diff, matches the current sentinel's hand-rolled style). Design should pick one; either is buildable on existing codegen.
  2. Lock-hold duration measurement: before committing to Option A's approach, get an order- of-magnitude estimate or measurement of collect_jobs+encode cost at a realistic job-table size, to confirm the read-lock hold is acceptably short (checkpoints already run on a snap_interval_s cadence via delay_task, off the hot request path).
  3. Format discriminator mechanics: exact byte-level marker to distinguish old 57-byte sentinel-only files from new entity-bearing files without breaking rotate_snapshots's existing filename-based selection.
  4. Trim-floor extension bookkeeping: where to track "standby X mid-install of snapshot LSN Y" — likely alongside the existing standby_conn_t/disconnected-slot state already used by repl_trim_lsn, but the exact struct field and its lifecycle (set on SEND_SNAPSHOT dispatch, cleared on the receiver's ack) needs design-phase detail.
  5. Interaction with ha_snapshot_offload_enabled() slow-lane path (ha.c ha_snapshot_send_dispatch): confirm the entity payload size doesn't change the offload flag-ON vs flag-OFF dispatch decision or chunking assumptions already in place for ha_handle_snapshot_chunk's multi-chunk reassembly.

Recommendation

Option A (extend write_snapshot/load_snapshot/trim_wal_segments in place, structured per Option C's seam discipline) for the design phase. Do not pursue Option B (kvmgr COW wiring) as part of this spec — it is a legitimate future improvement but was already deliberately deferred once, and this spec's actual requirements (R1-R5) do not need it: a bounded read-lock hold during a periodic, off-hot-path checkpoint is a reasonable cost, and it keeps this spec's blast radius to persist.c/ha.c/repl.c, matching the existing bug-1147 guard's pattern rather than touching the shared kvmgr.


Design Phase — Synthesis & Refinements (2026-07-01)

Key refinement over the gap analysis

The gap analysis (above) assumed trim_wal_segments/repl_trim_lsn needed a new floor input keyed on "a standby is currently mid-install of a snapshot." Closer reading of persist_checkpoint (persist.c:1763) shows this is already handled: write_snapshot runs before trim_wal_segments, and a non-zero write_snapshot return skips both trim_wal_segments and rotate_snapshots via the existing goto done. Once write_snapshot only returns 0 when the entity payload is fully and durably written (this spec's Requirement 1.3), that existing sequencing alone satisfies Requirement 3 — no new code needed in trim_wal_segments/ repl_trim_lsn.

The one retention hazard that genuinely has no existing guard is rotate_snapshots deleting a checkpoint file while a standby is still mid-transfer reading it over the wire (a checkpoint cadence faster than a slow transfer). This is the actual new mechanism this design adds: a snapshot_inflight_lsn[] array (mirroring the existing snapshot_inflight[] flag array in ha_state_t) that rotate_snapshots consults before deleting a candidate file.

Synthesis: Generalization

Requirements 1 and 2 both need "enumerate a live entity of kind K, encode it, and (for restore) decode+apply it." Rather than writing separate bespoke job-snapshot and queue-snapshot code paths, the design generalizes the interface (a small per-kind {collect, encode, decode+restore} triple) while keeping the implementation scoped to exactly the two kinds Requirement 1 names (job, queue) — not built out for hypothetical future entity kinds (e.g. workers), consistent with the requirement's explicit boundary.

Synthesis: Build vs. Adopt

Adopt, not build: the project's own Cap'n Proto schema and generated codecs for Job/ QueueConf already exist and are already proven via WAL replay's restore_job/ restore_queue_state. No external library, no new schema struct, no new wire protocol — this spec is pure reuse of already-solved, already-tested primitives.

Synthesis: Simplification

Rejected a new top-level capnp Snapshot message struct (would require a schema/build change for a one-off container) in favor of a hand-rolled length-prefixed frame sequence — matching the project's own existing WAL entry framing style (wal_entry_hdr_t + payload) and avoiding a new abstraction layer for something with exactly one caller. Rejected reviving the deferred zarray COW wiring (Option B) as unnecessary for this spec's scope — a bounded RTTI_LOCK_READ hold during an off-hot-path, periodic checkpoint is a reasonable cost that keeps this spec's blast radius to persist.c/ha.c, matching the existing bug-1147 guard's precedent rather than touching the shared kvmgr.

Design Decisions Log

  • On-disk format: legacy header (unchanged) + format_version + job_count + length-prefixed Job frames + queue_count + length-prefixed QueueConf frames. Legacy-file detection is "no bytes remain after the legacy header" — no explicit flag bit needed.
  • Consistency boundary: per-entity, not cross-entity — jobs and queues are independently locked/encoded in two sequential RTTI_LOCK_READ sections, never held simultaneously.
  • API change: persist_load_snapshot gains an out_entities_restored out-parameter so the caller can distinguish "fully restored" from "legacy position-only" without treating the latter as an error.
  • New guard location: rotate_snapshots (not trim_wal_segments), backed by a new snapshot_inflight_lsn[MAX_HA_CANDIDATES] array in ha_state_t, lifecycle-mirrored to the existing snapshot_inflight[] flag.

Design Validation Pass — Corrections (2026-07-01)

/kiro-validate-design found two real defects in the first design draft, both confirmed against code and fixed in design.md:

  1. Queue entity shape was wrong. The draft planned to serialize a full QueueConf capnp entity per queue, but restore_queue_state(cluster_t*, const char* name, uint32_t status) (queue.c:208) — the only existing queue-restore function — takes just a name and status word; its own comment states queues are config-built at startup and only admin status changes are WAL-tracked. Confirmed independently: queue definitions are already replicated via push_config_to_standbys (config_repl.c), a separate, already-committed, majority-acked channel — entirely out of this spec's boundary (user confirmation: "the basic configuration is not lived in cluster state snapshot as we have independent configuration"). Fix: the queue-side snapshot payload is now a plain (name, status) record, no capnp, restored via the unchanged restore_queue_state. This simplifies the design (no queue capnp encoder needed at all).
  2. The table-lock-only consistency argument was false. The original Architecture section claimed RTTI_LOCK_READ on c->jobmgr/c->qmgr (the table lock) excludes concurrent entity mutation. Verified false: restore_queue_state itself takes a separate per-entity lock(q, RTTI_LOCK_WRITE), and job state-transition sites (job.c:1006/1093/1137) take lock(j, RTTI_LOCK_WRITE) — a distinct lock object from the table lock. Fix: the entity-collection helper now nests each entity's own RTTI_LOCK_READ inside the table walk, around that entity's individual encode call — mirroring restore_queue_state's own nested-lock pattern. Added a new test item (concurrent-mutation-during-snapshot consistency) to prove this directly.

Both fixes are local repairs to the write-side collection logic and the queue payload format — the overall Option-A approach, boundary, and file structure plan are unchanged.