Snapshots
Loading a long-lived aggregate by replaying every event it has ever emitted gets
expensive as the stream grows. #[es_repo(snapshot)] lets a repo persist a
periodic fold of an entity’s state — a snapshot — in the same statement as
the events it appends, so every loader (flat or nested) reads the snapshot plus
only the events after it, in one round trip.
How It Works
- Your entity’s
eventsfield widens fromEntityEvents<E>toEntityEvents<E, S>, whereSis your own state type — the fold of events1..=kas of some sequencek. - You implement
HeadSnapshot::capture(&self) -> Option<S>, business logic that decides, on every write, whether the current state is worth persisting as the new snapshot head. - The repo’s
update/update_allfunctions callcapture()before their write statement and, if it returnsSome, upsert the state into a<tbl>_snapshotstable in the same statement as the event insert — no extra round trip. A brand-new entity is never snapshotted oncreate; it gets its first snapshot on its firstupdate. - Every loader reads
<tbl>_snapshots(matched by a fingerprint — see below) left-joined to only the events after it, and hands your entity aReplaystream: the snapshot first (if any), then the tail of events after it. - In memory,
EntityEvents<E, S>compacts itself right after a write that took a snapshot: the tail is dropped and replaced by the just-written state. A freshly-loaded entity and a freshly-updated one look identical. - A clean entity whose loaded state had no matching snapshot — a fingerprint
change, or one that predates
snapshotaltogether — refreshes on its nextupdate, even one that stages no events. Untouched siblings are never written to.
A repo without snapshot is unaffected: its generated SQL and .sqlx cache
are the same as before this feature existed.
Database Setup
Alongside your existing table and events table, add a <tbl>_snapshots table:
CREATE TABLE meters (
id UUID PRIMARY KEY,
label VARCHAR NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE meter_events (
id UUID NOT NULL REFERENCES meters(id),
sequence INT NOT NULL,
event_type VARCHAR NOT NULL,
event JSONB NOT NULL,
context JSONB DEFAULT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
UNIQUE(id, sequence)
);
CREATE TABLE meter_snapshots (
id UUID PRIMARY KEY REFERENCES meters(id),
sequence INT NOT NULL,
fingerprint BIGINT NOT NULL,
snapshot JSONB NOT NULL,
first_recorded_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL
);
sequence is the last event sequence folded into snapshot — the tail read
by a loader is exactly WHERE e.sequence > snapshot.sequence.
Defining a Snapshot Type
#[derive(EsSnapshot)] on a plain struct: every field is part of your fold.
extern crate es_entity;
extern crate sqlx;
extern crate serde;
use serde::{Deserialize, Serialize};
use es_entity::*;
es_entity::entity_id! { MeterId }
#[derive(EsSnapshot, Debug, Clone, Serialize, Deserialize)]
#[es_snapshot(version = 1)]
pub struct MeterSnapshot {
pub id: MeterId,
pub label: String,
pub count: u32,
pub total: i64,
}
fn main() {}
#[es_snapshot(version = N)] is mandatory — omitting it is a compile error —
and salts a fingerprint: an FNV-1a hash over the struct’s field names,
types, and version, computed once at macro-expansion time and embedded as a
literal i64. Every loader binds this value; a stored snapshot only joins
when its own fingerprint matches. Change the shape of MeterSnapshot
(add/remove/rename/retype a field) and the fingerprint changes with it — old
rows simply stop matching and every loader falls back to a full replay for
that entity, self-healing the next time it writes a fresh snapshot. Bump
version by hand for a change the derive can’t see (e.g. a change in how a
field is interpreted, not its Rust type) — that’s the whole reason the
attribute is required rather than defaulted: it forces the question to be
asked instead of silently assuming “no semantic change.”
A fingerprint mismatch is never an error — it’s the mechanism that makes schema evolution safe without a migration step. During a rolling deploy, old and new pods can briefly disagree on the fingerprint and flip a row back and forth on writes; every version written is a correct fold for its own reader, so the only cost is a few extra writes until the rollout finishes.
The HeadSnapshot Trait and Replay
Your entity implements HeadSnapshot::capture(), and every place that used to
fold over events.iter_all() switches to events.replay(), which yields
Replay::Snapshot(&S) (at most once, first) then Replay::Event(&E) for the
tail:
extern crate es_entity;
extern crate sqlx;
extern crate serde;
use serde::{Deserialize, Serialize};
use es_entity::*;
es_entity::entity_id! { MeterId }
#[derive(EsEvent, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[es_event(id = "MeterId")]
pub enum MeterEvent {
Initialized { id: MeterId, label: String },
ReadingRecorded { value: i64 },
}
#[derive(EsSnapshot, Debug, Clone, Serialize, Deserialize)]
#[es_snapshot(version = 1)]
pub struct MeterSnapshot { pub id: MeterId, pub label: String, pub count: u32, pub total: i64 }
#[derive(EsEntity)]
pub struct Meter {
pub id: MeterId,
pub label: String,
events: EntityEvents<MeterEvent, MeterSnapshot>,
}
impl Meter {
pub fn total(&self) -> i64 {
self.events.replay().fold(0, |acc, r| match r {
// The snapshot already IS the fold up to its own head — seed
// from it, don't recompute it.
Replay::Snapshot(s) => s.total,
Replay::Event(MeterEvent::ReadingRecorded { value }) => acc + value,
Replay::Event(_) => acc,
})
}
}
impl HeadSnapshot for Meter {
fn capture(&self) -> Option<MeterSnapshot> {
// Business logic decides when a snapshot is worth taking — here,
// once four events have accumulated since the last one.
(self.events.tail_len() >= 4).then(|| MeterSnapshot {
id: self.id,
label: self.label.clone(),
count: self.total() as u32, // fold already includes the tail
total: self.total(),
})
}
}
impl TryFromEvents<MeterEvent, MeterSnapshot> for Meter {
fn try_from_events(events: EntityEvents<MeterEvent, MeterSnapshot>) -> Result<Self, EntityHydrationError> {
let mut hydrator = Meter::hydrator();
for r in events.replay() {
match r {
Replay::Snapshot(s) => { hydrator.id(s.id).label(s.label.clone()); }
Replay::Event(MeterEvent::Initialized { id, label }) => { hydrator.id(*id).label(label.clone()); }
Replay::Event(_) => {}
}
}
hydrator.build(events)
}
}
pub struct NewMeter { pub label: String }
impl IntoEvents<MeterEvent> for NewMeter {
type IdSource = FromRepo;
fn into_events(self, builder: EntityEventsBuilder<MeterEvent, FromRepo>) -> EntityEvents<MeterEvent> {
let id = builder.id();
builder.initial_events([MeterEvent::Initialized { id, label: self.label }])
}
}
fn main() {}
Replay is deliberately not #[non_exhaustive]: a match over it must
name every variant, so adding a snapshot to an entity that used to be
NoSnapshot turns every fold that forgot to account for Replay::Snapshot
into a compile error, rather than a silent under-count. EntityEvents<E, NoSnapshot>::iter_all() — the pre-snapshot API — does not exist for a widened
EntityEvents<E, S> at all, for the same reason.
idempotency_guard! on a Snapshotted Stream
A snapshot can carry the answer an idempotency guard is looking for.
idempotency_guard! gains a required snapshot: clause once its stream
yields Replay, so a guard that never considered the snapshot fails to
compile rather than silently missing it once the tail has been compacted
away:
idempotency_guard!(
self.events.replay().rev(),
already_applied: MeterEvent::ReadingRecorded { value: v } if *v == value,
resets_on: MeterEvent::ReadingRecorded { .. },
snapshot: s if s.last_value == Some(value),
);
Repository Setup
#[derive(EsRepo, Debug)]
#[es_repo(entity = "Meter", snapshot, columns(label(ty = "String")))]
pub struct MeterRepo {
pool: PgPool,
}
repo.find_by_id, find_all, list_by_*, create, update, and their
_all/_in_op twins all keep their existing signatures — the snapshot is
entirely a storage-layer concern. There is no separate backfill step: an
entity that predates snapshot being enabled gets its first snapshot the
same way a fingerprint change heals — on its next update.
Nesting
Nested parents and children snapshot independently — any combination (both, parent only, child only, neither) just works, in one statement per load. See Nesting.
Forgettable Fields on a Snapshot
A Forgettable<T> field on your snapshot type is scrubbed the same way as on
an event: the repo also needs forgettable, and the real value is extracted
into the existing <tbl>_forgettable_payloads table, at the reserved
sequence = 0 row (event sequences start at 1, so 0 is always free — no
new table). forget() on a snapshot repo deletes that row along with the
event-level ones, deletes the stale snapshot row, and immediately re-snapshots
from the rebuilt (forgotten) state, so a snapshot never has a chance to hold a
value that should have been forgotten. See Forgettable Data.