Skip to content

feat(fjall): projection SnapshotStore<Vec<u8>, GlobalSeq> adapter (#164)#297

Merged
joeldsouzax merged 3 commits into
mainfrom
feat/164-fjall-projection-snapshotstore
Jul 5, 2026
Merged

feat(fjall): projection SnapshotStore<Vec<u8>, GlobalSeq> adapter (#164)#297
joeldsouzax merged 3 commits into
mainfrom
feat/164-fjall-projection-snapshotstore

Conversation

@joeldsouzax

Copy link
Copy Markdown
Contributor

Closes #164.

What

Adds a fjall-backed SnapshotStore<Vec<u8>, GlobalSeq> for projection state, behind a new projection feature. A projection's folded state and the $all GlobalSeq it was folded up to are committed together into one value under one key in a dedicated projections partition.

Design

  • Distinct partition. projections is a separate point-read keyspace from snapshots, so a projection id can never collide with an aggregate-snapshot id sharing the same bytes.
  • Coherent coexistence. The two SnapshotStore impls on FjallStore are distinct trait instantiations (P = Version vs P = GlobalSeq) — no coherence conflict.
  • Atomicity for free. commit is a single-key insert of the combined [u32 LE schema_version][u64 BE position][payload] value (the existing snapshot blob layout — position is the GlobalSeq's u64, no new encoder). A half-write of state without its position is unrepresentable, same guarantee as the snapshot path.
  • hydrate returns None on schema-version mismatch, parity with the snapshot impl. A stored GlobalSeq of 0 is impossible, so a well-formed header carrying a zero position maps to CorruptValue.

Incidental

  • Partitions::new now takes the event-frame pair as one (events, events_global) tuple, keeping arity within the project's too-many-arguments-threshold = 5 after the new projections keyspace. That forfeits const (const eval can't drop the tuple), which was never load-bearing here (new is only called at runtime from open).
  • hydrate call sites in both fjall test files are disambiguated (turbofish) because under --all-features both SnapshotStore impls are in scope and hydrate's P is return-position only.

Tests (acceptance)

  • FjallStore: SnapshotStore<Vec<u8>, GlobalSeq> behind projection, on a projections partition
  • Atomic commit (state + GlobalSeq in one value)
  • hydrate returns None on schema-version mismatch
  • Lifecycle: commit → reopen → hydrate returns the same (GlobalSeq, state)
  • Defensive: corrupt/too-short bytes → CorruptValue, not a panic (white-box); plus a zero-GlobalSeq corruption test

9 public-API tests (roundtrip, overwrite, reopen lifecycle, schema mismatch, GlobalSeq boundaries incl. INITIAL/u64::MAX, empty state, isolation) + 2 white-box defensive tests. Green under nix flake check and cargo clippy/test -p nexus-fjall --all-targets --all-features.

🤖 Generated with Claude Code

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>
@joeldsouzax joeldsouzax force-pushed the feat/164-fjall-projection-snapshotstore branch from e16309b to eefa6de Compare July 5, 2026 19:36
@joeldsouzax

Copy link
Copy Markdown
Contributor Author

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 projections partition is that an aggregate snapshot and a projection checkpoint sharing the same id bytes must not clobber each other — and the original suite only tested projection-vs-projection isolation. snapshot_and_projection_with_same_id_do_not_collide now commits both stores at id "shared-id" and asserts each hydrates its own value. It fails if the two partitions are ever merged (a shared keyspace would let the second commit overwrite the first). It needs both features, so the self dev-dependency now enables snapshot too — which also closes a pre-existing gap where snapshot_tests.rs never ran under the default-feature gate.

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 commit (IoT power-loss) costs an idempotent re-fold, never a skipped event. Docs in write_projection, the impl module, and CLAUDE.md now say that.

Deferred to a design thread (not this PR): whether aggregate snapshots and projection checkpoints should keep sharing SnapshotStore<S, P> at all (the two-impl hydrate ambiguity is the symptom), and whether hydrate returning None on schema-mismatch — which for a projection means re-folding all of $all — should be a silent inherited behavior. Both trace back to the #163 unification.

joeldsouzax and others added 2 commits July 5, 2026 22:28
… 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>
@joeldsouzax joeldsouzax enabled auto-merge (squash) July 5, 2026 21:27
@joeldsouzax joeldsouzax merged commit 52d5bda into main Jul 5, 2026
4 checks passed
@joeldsouzax joeldsouzax deleted the feat/164-fjall-projection-snapshotstore branch July 5, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(fjall): add projection SnapshotStore<Vec<u8>, GlobalSeq> adapter

1 participant