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
205 lines
18 KiB
Markdown
205 lines
18 KiB
Markdown
# 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
|
|
|
|
### Option A: Extend existing components in place (RECOMMENDED)
|
|
- 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.
|