Rehydrating an aggregate means replaying its whole stream. For a long stream that gets slow, and a snapshot is the usual fix: periodically save the folded state plus the position it was folded to, and on load start from there instead of from event one.
mnesis supports snapshots — but first, a strong recommendation.
Prefer Closing the Books to snapshots whenever your domain has a natural cycle. A summary event flows through the same fold and the same upcaster pipeline as any event; a snapshot is opaque bytes with no migration path. Reach for a snapshot only when you genuinely cannot bound the stream.
One trait powers both aggregate snapshots and projection state:
pub trait SnapshotStore<S, P>: Send + Sync {
async fn hydrate(&self, id, schema_version) -> Result<Hydrated<S, P>, Self::Error>;
async fn commit(&self, id, schema_version, position: P, state: &S) -> Result<(), Self::Error>;
}State and position are saved and loaded together, never separately. That is the key safety property: a persisted checkpoint can never be ahead of the state it describes, so a lost write costs a re-fold (idempotent), never a skipped event. It is a structural guarantee — it does not depend on fsync timing.
P is the position type: Version for a single aggregate stream, GlobalSeq
for a multi-stream projection.
hydrate returns a trichotomy, not Option:
pub enum Hydrated<S, P> {
Absent, // nothing saved — start empty / replay
Stale { stored_schema }, // saved under a *different* schema — must rebuild
Found { position, state }, // usable checkpoint
}Why the extra state matters: for an aggregate snapshot, Absent and Stale both
just mean "replay the stream," and the decorator collapses them. But for a
projection, Stale means a schema bump invalidated the saved state and the
next step is a full re-fold of the whole $all stream — on a mobile/IoT host,
a long, battery-heavy operation the host must be able to see coming. Stale
deliberately carries no stale bytes: derived state has no upcasting path, so a
schema change forces a rebuild, never a migration.
You never call SnapshotStore by hand for an aggregate. Configure it on the
repository builder and it becomes transparent:
let repo = store
.repository::<BankAccount>()
.json()
.snapshot_store_json(snapshot_store) // wrap in Snapshotting
.build();Now load hydrates from the snapshot (falling back to replay if Absent/Stale)
and save commits a fresh snapshot per a PersistTrigger. Snapshot writes are
best-effort — they never block event persistence; a failed snapshot just
means the next load re-folds.
EveryNEvents(n)— snapshot each time the version crosses a bucket ofn.AfterEventTypes(&[..])— snapshot after semantically meaningful events.
Adapters implement SnapshotStore<Vec<u8>, P> (raw bytes). CodecSnapshotStore
bridges that to a typed SnapshotStore<S, P> using your Encode/Decode, so the
same codec serializes both events and snapshots.
- Closing the Books — the preferred alternative.
- Repository — wiring snapshots in.
- CQRS — projections use the same
SnapshotStorefor their checkpoint.