Snapshots
Shorten recovery with snapshots, so a device does not replay its whole history.
A device which has recorded a reading every second for a year has a long history. Replaying all of it on every start takes time. A snapshot stores the state at some point, so recovery starts from the latest snapshot and replays only the events after it.
The state as a snapshot
#[derive(Clone, Copy, Default, Serialize, Deserialize)]
struct Readings {
last: Option<f64>,
count: u64,
}
impl Versioned for Readings {
const MANIFEST: &'static str = "readings";
const VERSION: SchemaVersion = SchemaVersion::new(1);
}The state now also counts the readings, so it can decide when a snapshot is due. It doubles as the snapshot type, so it is Versioned like an event.
fn apply(&self, readings: Self::State, Recorded(degrees): Self::Event) -> Self::State {
Readings {
last: Some(degrees),
count: readings.count + 1,
}
}
fn snapshot(&self, readings: &Self::State) -> Result<Option<Self::Snapshot>, Self::Error> {
Ok((readings.count % SNAPSHOT_EVERY == 0).then_some(*readings))
}After the events of an effect have been applied, tellus asks snapshot whether one is due. The device offers one after every third reading.
fn init(&self) -> Result<Self::State, Self::Error> {
Ok(Readings::default())
}
fn init_from_snapshot(&self, snapshot: Self::Snapshot) -> Result<Self::State, Self::Error> {
Ok(snapshot)
}On recovery, if there is a snapshot, init_from_snapshot turns it into the state, and only the events after the snapshot are replayed. Without a snapshot, init is the starting point, as before. Because of this, init must stay pure: it is skipped as soon as a snapshot exists. Work which touches the world at start, like spawning children, belongs in recovered, which runs after every recovery.
A snapshot is never the source of truth. The events are. If a snapshot cannot be saved or later no longer decoded, recovery simply replays more events.
Run it
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let store = InMemoryStore::default();
let persistence = Persistence::new(store.clone()).with_snapshot_store(store.clone());
let system = ActorSystem::event_sourced(Device, persistence.clone());
for degrees in [21.5, 22.0, 22.4, 22.1, 21.8, 21.9, 22.3] {
system.root().tell(Command::Record(degrees));
}
system
.root()
.ask(Duration::from_secs(1), Command::Read)
.await
.context("asking for the readings")?;
system.stop();
system
.terminated()
.await
.context("awaiting the first actor system's termination")?;
let id = Device.persistence_id();
let snapshot = store.snapshot(&id).context("no snapshot was saved")?;
println!(
"Stored events: {}, the snapshot covers the first {}",
store.events(&id).len(),
snapshot.next_seq_no.as_u64()
);
let system = ActorSystem::event_sourced(Device, persistence);
let readings = system
.root()
.ask(Duration::from_secs(1), Command::Read)
.await
.context("asking for the readings")?;
if let Some(degrees) = readings.last {
println!(
"After recovery: {} readings, the last one {degrees:.1}",
readings.count
);
}
system.stop();
system
.terminated()
.await
.context("awaiting the second actor system's termination")
}The snapshot store is configured with with_snapshot_store. The in-memory store serves both roles.
cargo run --quiet -p tellus --features persistence-in-memory --example event_sourced_03_snapshotsStored events: 7, the snapshot covers the first 6
After recovery: 7 readings, the last one 22.3The second system starts from the snapshot after six readings and replays only the seventh event.
What you learned
Snapshots shorten recovery. snapshot decides when one is due, init_from_snapshot starts from it, and the events remain the source of truth. Last, the device moves to a real database.