Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Your entity’s events field widens from EntityEvents<E> to EntityEvents<E, S>, where S is your own state type — the fold of events 1..=k as of some sequence k.
  2. 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.
  3. The repo’s update/update_all functions call capture() before their write statement and, if it returns Some, upsert the state into a <tbl>_snapshots table in the same statement as the event insert — no extra round trip. A brand-new entity is never snapshotted on create; it gets its first snapshot on its first update.
  4. Every loader reads <tbl>_snapshots (matched by a fingerprint — see below) left-joined to only the events after it, and hands your entity a Replay stream: the snapshot first (if any), then the tail of events after it.
  5. 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.
  6. A clean entity whose loaded state had no matching snapshot — a fingerprint change, or one that predates snapshot altogether — refreshes on its next update, 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.