feat(fjall): projection SnapshotStore<Vec<u8>, GlobalSeq> adapter (#164)#297
Conversation
Add a fjall-backed `SnapshotStore<Vec<u8>, GlobalSeq>` for projection state, behind a new `projection` feature. Projection state and the `$all` `GlobalSeq` it was folded up to are committed together into one value under one key in a dedicated `projections` partition (distinct from `snapshots`, so a projection id can never collide with an aggregate-snapshot id sharing the same bytes). The two `SnapshotStore` impls are distinct trait instantiations (`P = Version` vs `P = GlobalSeq`), so both coexist on `FjallStore` with no coherence conflict. The value reuses the snapshot blob layout `[u32 LE schema_version] [u64 BE position][payload]` — no new encoder shape. Atomicity is structural, not fsync-dependent: because state and position are one value under one key, a persisted checkpoint can never be ahead of the state it describes, so a lost `commit` costs an idempotent re-fold, never a skipped event. That is why projection checkpointing needs no cross-partition tx. `Partitions::new` now takes the event-frame pair as one `(events, events_global)` tuple to stay within the project's 5-argument clippy ceiling after the new `projections` keyspace; this forfeits `const` (const eval cannot drop the tuple), which was never load-bearing. Tests: 10 public-API tests (roundtrip, overwrite, reopen lifecycle, schema mismatch, GlobalSeq boundaries, isolation, and the snapshot-vs-projection same-id collision test that defends the partition split) plus 2 white-box defensive tests (too-short bytes and a zero GlobalSeq both surface `CorruptValue`, not a panic). The self dev-dependency now enables `snapshot` too, so both the collision test and the previously-uncovered snapshot tests run under the default-feature gate. `hydrate` call sites in both fjall test files are disambiguated for `--all-features`, where both impls are in scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e16309b to
eefa6de
Compare
|
Follow-up hardening after a principal-level self-review (rule 9 — measure/question rather than inherit): 1. Added the test that actually defends the design. The whole point of the separate 2. Restated atomicity structurally, not operationally. The earlier doc claimed "single-key insert is atomic." The real, stronger guarantee doesn't depend on fjall's fsync semantics: because state and position are one value under one key, a persisted checkpoint can never be ahead of the state it describes, so a lost Deferred to a design thread (not this PR): whether aggregate snapshots and projection checkpoints should keep sharing |
… stale
`SnapshotStore::hydrate` returned `Option<(P, S)>`, collapsing "nothing
saved" and "saved, but under a different schema version" into the same
`None`. For an aggregate snapshot that's fine — both mean replay. For a
projection it is not: a stale checkpoint means a schema bump invalidated the
state and the next step is a full re-fold of the whole `$all` stream, which
on a mobile/IoT host is a long, battery-heavy operation the host must be able
to see coming (warn, defer to Wi-Fi/charging, throttle).
Change `hydrate` to return a three-state `Hydrated<S, P>`:
`Absent | Stale { stored_schema } | Found { position, state }`. `Stale`
carries no bytes (derived state has no upcasting path — a schema change
forces a rebuild, never a migration). `Hydrated::into_found` collapses
absent+stale for callers that treat them alike (the aggregate-snapshot
decorator). Both fjall impls now validate the stored schema is `NonZeroU32`
(a zero is corruption, not staleness) before branching Stale vs Found.
`Projection::load` keeps its `(stepper, state)` signature; the new signal
rides on a single accessor `rebuilding_from() -> Option<NonZeroU32>`
(`Some(old_schema)` on a Stale rebuild, `None` for fresh/resumed —
disambiguated from a resume via `checkpoint()`). Deliberately NO second
`Start` enum duplicating `Hydrated`'s trichotomy.
Tests upgraded to assert the exact variant (`Absent`/`Stale`/`Found`) where
they previously asserted `is_none`, plus new stepper tests that a stale
schema signals `rebuilding_from()` and a fresh start does not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two projection-partition design choices were previously inherited from the snapshot path without measurement (CLAUDE rule 9). Both are now measured by a new reproducible bench, `benches/projection_storage` (deterministic, decision metric = fjall `disk_space()`), and decided: Fork 1 — partition config. The `projections` partition used `point_read_defaults`, which barely compresses projection state (fjall's default `[None,None,Lz4]` only engages at deep levels). Projection state is larger and more compressible (structured records/JSON) than a snapshot; all-levels LZ4 shrinks compressible state 10-100x on disk with no measurable cost on incompressible payloads. On a flash-constrained IoT/mobile target that is a one-sided win, so a dedicated `projection_defaults` (point-read blocks + bloom PLUS all-levels LZ4) now backs the partition. LZ4 is transparent on read; existing roundtrip tests pass unchanged. (Marked `!` — on-disk block encoding of the projections partition changes, though reads of old blocks stay compatible.) Fork 2 — separate partition vs key-prefix. Measured +0.0% on disk between a separate `projections` partition and a single key-prefixed one. The split is therefore free in storage and buys collision-safety by construction plus independent per-partition config (which Fork 1 shows matters), at the cost of one extra memtable's RAM. Kept separate. Fork 3 (the two `SnapshotStore` impls sharing one trait) is left unified — a decided API judgment, no runtime fork to benchmark. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes #164.
What
Adds a fjall-backed
SnapshotStore<Vec<u8>, GlobalSeq>for projection state, behind a newprojectionfeature. A projection's folded state and the$allGlobalSeqit was folded up to are committed together into one value under one key in a dedicatedprojectionspartition.Design
projectionsis a separate point-read keyspace fromsnapshots, so a projection id can never collide with an aggregate-snapshot id sharing the same bytes.SnapshotStoreimpls onFjallStoreare distinct trait instantiations (P = VersionvsP = GlobalSeq) — no coherence conflict.commitis a single-key insert of the combined[u32 LE schema_version][u64 BE position][payload]value (the existing snapshot blob layout —positionis theGlobalSeq'su64, no new encoder). A half-write of state without its position is unrepresentable, same guarantee as the snapshot path.hydratereturnsNoneon schema-version mismatch, parity with the snapshot impl. A storedGlobalSeqof 0 is impossible, so a well-formed header carrying a zero position maps toCorruptValue.Incidental
Partitions::newnow takes the event-frame pair as one(events, events_global)tuple, keeping arity within the project'stoo-many-arguments-threshold = 5after the newprojectionskeyspace. That forfeitsconst(const eval can't drop the tuple), which was never load-bearing here (newis only called at runtime fromopen).hydratecall sites in both fjall test files are disambiguated (turbofish) because under--all-featuresbothSnapshotStoreimpls are in scope andhydrate'sPis return-position only.Tests (acceptance)
FjallStore: SnapshotStore<Vec<u8>, GlobalSeq>behindprojection, on aprojectionspartitioncommit(state +GlobalSeqin one value)hydratereturnsNoneon schema-version mismatch(GlobalSeq, state)CorruptValue, not a panic (white-box); plus a zero-GlobalSeqcorruption test9 public-API tests (roundtrip, overwrite, reopen lifecycle, schema mismatch,
GlobalSeqboundaries incl.INITIAL/u64::MAX, empty state, isolation) + 2 white-box defensive tests. Green undernix flake checkandcargo clippy/test -p nexus-fjall --all-targets --all-features.🤖 Generated with Claude Code