mako_engine/snapshot.rs
1//! Snapshotting support — a performance optimisation for long event streams.
2//!
3//! Snapshots are **never the source of truth**. They are always rebuildable
4//! by replaying the full event stream. The engine must function correctly if
5//! all snapshots are discarded.
6//!
7//! # Policy
8//!
9//! A common policy is to take a snapshot every N events (e.g. N = 100).
10//! Use [`Snapshot::should_take`] to check whether enough events have
11//! accumulated since the last snapshot.
12//!
13//! ```rust
14//! use mako_engine::snapshot::Snapshot;
15//!
16//! // Take a snapshot every 100 events.
17//! // second argument is the sequence_number of the last snapshot (0 = none).
18//! assert!(!Snapshot::should_take(99, 0, 100));
19//! assert!( Snapshot::should_take(100, 0, 100));
20//! // Non-exact counts still trigger once the threshold is exceeded:
21//! assert!( Snapshot::should_take(101, 0, 100));
22//! // After a snapshot at seq 100, next triggers at 200+:
23//! assert!(!Snapshot::should_take(101, 100, 100));
24//! assert!( Snapshot::should_take(200, 100, 100));
25//! // interval = 0 disables snapshotting (never returns true):
26//! assert!(!Snapshot::should_take(1000, 0, 0));
27//! ```
28//!
29//! # Using the store
30//!
31//! ```rust,ignore
32//! use mako_engine::snapshot::{Snapshot, SnapshotStore};
33//!
34//! // After executing a command:
35//! let event_count = process.event_count().await?;
36//! let last_snap = snap_store.load(process.stream_id()).await?
37//! .map_or(0, |s| s.sequence_number);
38//! if Snapshot::should_take(event_count, last_snap, 100) {
39//! let state = process.state().await?;
40//! let payload = serde_json::to_value(&state)?;
41//! let snap = Snapshot::new(process.stream_id().clone(), event_count, 1, payload);
42//! snap_store.save(&snap).await?;
43//! }
44//! ```
45
46use std::sync::Arc;
47
48#[cfg(any(test, feature = "testing"))]
49use std::collections::HashMap;
50#[cfg(any(test, feature = "testing"))]
51use tokio::sync::RwLock;
52
53use serde_json::Value;
54
55use crate::{error::EngineError, ids::StreamId};
56
57// ── Snapshot ──────────────────────────────────────────────────────────────────
58
59/// A point-in-time snapshot of an aggregate's state.
60///
61/// A snapshot carries the serialized state at a specific `sequence_number`.
62/// During state reconstruction, the engine loads the snapshot (if any) and
63/// then replays only the events that arrived after it.
64///
65/// ## Schema versioning
66///
67/// `state_schema_version` mirrors `EventEnvelope::schema_version`. Increment
68/// it when the serialized state layout changes incompatibly. The engine must
69/// discard snapshots whose schema version it does not recognise.
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct Snapshot {
72 /// The stream this snapshot covers.
73 pub stream_id: StreamId,
74
75 /// The sequence number of the last event incorporated into this snapshot.
76 ///
77 /// Events with a higher sequence number must still be replayed on top.
78 pub sequence_number: u64,
79
80 /// Schema version of the serialized `state` payload.
81 pub state_schema_version: u32,
82
83 /// The serialized aggregate state at `sequence_number`.
84 ///
85 /// Stored as [`serde_json::Value`] so the engine layer remains
86 /// domain-agnostic; the domain crate deserializes it into the concrete
87 /// `Workflow::State` type.
88 pub state: Value,
89}
90
91impl Snapshot {
92 /// Construct a new snapshot.
93 #[must_use]
94 pub fn new(
95 stream_id: StreamId,
96 sequence_number: u64,
97 state_schema_version: u32,
98 state: Value,
99 ) -> Self {
100 Self {
101 stream_id,
102 sequence_number,
103 state_schema_version,
104 state,
105 }
106 }
107
108 /// Return `true` when a snapshot should be taken.
109 ///
110 /// Returns `true` when `event_count - last_snapshot_at >= interval`,
111 /// i.e. at least `interval` new events have accumulated since the last
112 /// snapshot. This avoids the exact-multiple trap: if snapshotting is
113 /// skipped at count 100, it still triggers at 101, 102, etc. until a
114 /// snapshot is taken.
115 ///
116 /// Set `last_snapshot_at` to `0` when no snapshot has ever been taken.
117 ///
118 /// Returns `false` when `interval` is `0` (snapshotting disabled).
119 ///
120 /// # Example
121 ///
122 /// ```
123 /// use mako_engine::snapshot::Snapshot;
124 ///
125 /// // First snapshot: no prior snapshot (last_snapshot_at = 0).
126 /// assert!(!Snapshot::should_take(99, 0, 100));
127 /// assert!( Snapshot::should_take(100, 0, 100));
128 /// assert!( Snapshot::should_take(101, 0, 100)); // non-exact still triggers
129 ///
130 /// // After a snapshot at seq 100, next triggers at 200+.
131 /// assert!(!Snapshot::should_take(101, 100, 100));
132 /// assert!( Snapshot::should_take(200, 100, 100));
133 /// assert!( Snapshot::should_take(201, 100, 100));
134 ///
135 /// // interval = 0 disables snapshotting.
136 /// assert!(!Snapshot::should_take(1000, 0, 0));
137 /// ```
138 #[must_use]
139 pub fn should_take(event_count: u64, last_snapshot_at: u64, interval: u64) -> bool {
140 if interval == 0 {
141 return false;
142 }
143 event_count > 0 && event_count.saturating_sub(last_snapshot_at) >= interval
144 }
145}
146
147// ── SnapshotStore ─────────────────────────────────────────────────────────────
148
149/// Storage contract for aggregate snapshots.
150///
151/// Implementations live in separate backend crates
152/// (`mako-event-store-slatedb`, `mako-event-store-redb`, etc.).
153/// [`NoopSnapshotStore`] is provided here for tests and deployments that do not
154/// need snapshotting.
155///
156/// ## Contract
157///
158/// - `save` must persist the snapshot durably before returning `Ok`.
159/// - `load` must return the **most recent** snapshot for `stream_id`, or
160/// `None` if no snapshot exists.
161/// - A snapshot must never be returned for a `stream_id` if its
162/// `state_schema_version` is not recognised by the caller — implementations
163/// are encouraged to store schema version in a queryable column/key so
164/// callers can filter by it.
165///
166/// ## Blanket `Arc` implementation
167///
168/// `Arc<S>` implements `SnapshotStore` whenever `S: SnapshotStore`, so
169/// `Process<W, Arc<MyEventStore>>` can accept `Arc<MySnapshotStore>` without
170/// any extra wrapper.
171#[allow(async_fn_in_trait)]
172pub trait SnapshotStore: Send + Sync {
173 /// Persist `snapshot`, replacing any previous snapshot for the same stream.
174 ///
175 /// # Errors
176 ///
177 /// Returns [`EngineError::Snapshot`] on storage failure.
178 #[must_use = "dropping a save Result silently discards a snapshot error"]
179 async fn save(&self, snapshot: &Snapshot) -> Result<(), EngineError>;
180
181 /// Load the most recent snapshot for `stream_id`.
182 ///
183 /// Returns `None` when no snapshot exists (full replay required).
184 ///
185 /// # Errors
186 ///
187 /// Returns [`EngineError::Snapshot`] on storage failure.
188 #[must_use = "dropping a load Result silently discards a snapshot error"]
189 async fn load(&self, stream_id: &StreamId) -> Result<Option<Snapshot>, EngineError>;
190}
191
192// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
193
194impl<S: SnapshotStore> SnapshotStore for Arc<S> {
195 async fn save(&self, snapshot: &Snapshot) -> Result<(), EngineError> {
196 self.as_ref().save(snapshot).await
197 }
198
199 async fn load(&self, stream_id: &StreamId) -> Result<Option<Snapshot>, EngineError> {
200 self.as_ref().load(stream_id).await
201 }
202}
203
204// ── NoopSnapshotStore ─────────────────────────────────────────────────────────
205
206/// A [`SnapshotStore`] that never persists anything.
207///
208/// Every `load` returns `None` (full replay); every `save` succeeds silently.
209///
210/// # ⚠️ Data loss warning
211///
212/// `NoopSnapshotStore` **discards every snapshot silently**. Processes built
213/// with this store perform full event replay on every state read. Do not use
214/// in production — bind a `SlateDbStore::as_snapshot_store()` (requires `slatedb`
215/// feature) instead.
216///
217/// The [`SnapshotStore`] implementation is only compiled when the `testing`
218/// feature is enabled or inside `#[cfg(test)]`. Production binaries must call
219/// `EngineBuilder::with_snapshot_store` with a durable backend.
220#[derive(Debug, Clone, Copy, Default)]
221#[must_use = "NoopSnapshotStore discards all snapshots silently — use a persistent SnapshotStore in production"]
222#[cfg_attr(
223 not(any(test, feature = "testing")),
224 deprecated = "NoopSnapshotStore must not be instantiated in production builds; use a durable SnapshotStore instead"
225)]
226pub struct NoopSnapshotStore;
227
228#[cfg(any(test, feature = "testing"))]
229impl SnapshotStore for NoopSnapshotStore {
230 async fn save(&self, _snapshot: &Snapshot) -> Result<(), EngineError> {
231 Ok(())
232 }
233
234 async fn load(&self, _stream_id: &StreamId) -> Result<Option<Snapshot>, EngineError> {
235 Ok(None)
236 }
237}
238
239// ── InMemorySnapshotStore ─────────────────────────────────────────────────────
240
241/// An in-memory [`SnapshotStore`] for tests and development.
242///
243/// Stores the **most recent snapshot per stream**. Cloning shares the
244/// underlying data via `Arc` — all clones see the same snapshots.
245///
246/// Use this with [`Process::take_snapshot`] and
247/// [`Process::state_with_snapshot`] to verify snapshot-accelerated replay
248/// without depending on an external storage backend.
249///
250/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
251///
252/// [`Process::take_snapshot`]: crate::process::Process::take_snapshot
253/// [`Process::state_with_snapshot`]: crate::process::Process::state_with_snapshot
254#[cfg(any(test, feature = "testing"))]
255#[derive(Debug, Default, Clone)]
256pub struct InMemorySnapshotStore {
257 inner: Arc<RwLock<HashMap<StreamId, Snapshot>>>,
258}
259
260#[cfg(any(test, feature = "testing"))]
261impl InMemorySnapshotStore {
262 /// Create an empty snapshot store.
263 #[must_use]
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 /// Return `true` when no snapshots are stored.
269 pub async fn is_empty(&self) -> bool {
270 self.inner.read().await.is_empty()
271 }
272}
273
274#[cfg(any(test, feature = "testing"))]
275impl SnapshotStore for InMemorySnapshotStore {
276 async fn save(&self, snapshot: &Snapshot) -> Result<(), EngineError> {
277 self.inner
278 .write()
279 .await
280 .insert(snapshot.stream_id.clone(), snapshot.clone());
281 Ok(())
282 }
283
284 async fn load(&self, stream_id: &StreamId) -> Result<Option<Snapshot>, EngineError> {
285 Ok(self.inner.read().await.get(stream_id).cloned())
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn should_take_at_exact_multiples() {
295 // No previous snapshot (last_snapshot_at = 0).
296 assert!(!Snapshot::should_take(0, 0, 100));
297 assert!(!Snapshot::should_take(99, 0, 100));
298 assert!(Snapshot::should_take(100, 0, 100));
299 // Non-exact still triggers ("at-least" semantics):
300 assert!(Snapshot::should_take(101, 0, 100));
301 assert!(Snapshot::should_take(200, 0, 100));
302 // After snapshot at 100, next triggers at 200+:
303 assert!(!Snapshot::should_take(101, 100, 100));
304 assert!(Snapshot::should_take(200, 100, 100));
305 assert!(Snapshot::should_take(250, 100, 100));
306 }
307
308 #[test]
309 fn should_take_interval_one() {
310 // Every event triggers a snapshot (last_snapshot_at = prev count).
311 for i in 1u64..=5 {
312 assert!(Snapshot::should_take(i, i - 1, 1));
313 }
314 }
315
316 #[tokio::test]
317 async fn noop_store_always_returns_none() {
318 let store = NoopSnapshotStore;
319 let stream = StreamId::new("process/test");
320 assert!(store.load(&stream).await.unwrap().is_none());
321 }
322
323 #[tokio::test]
324 async fn noop_store_save_succeeds() {
325 let store = NoopSnapshotStore;
326 let snap = Snapshot::new(
327 StreamId::new("process/test"),
328 10,
329 1,
330 serde_json::json!({"status": "Active"}),
331 );
332 assert!(store.save(&snap).await.is_ok());
333 }
334
335 // ── InMemorySnapshotStore ─────────────────────────────────────────────────
336
337 #[tokio::test]
338 async fn in_memory_store_round_trip() {
339 let store = InMemorySnapshotStore::new();
340 let stream = StreamId::new("process/abc");
341 let snap = Snapshot::new(stream.clone(), 5, 1, serde_json::json!({"x": 1}));
342
343 store.save(&snap).await.unwrap();
344
345 let loaded = store
346 .load(&stream)
347 .await
348 .unwrap()
349 .expect("snapshot must exist");
350 assert_eq!(loaded.sequence_number, 5);
351 assert_eq!(loaded.state_schema_version, 1);
352 assert_eq!(loaded.state, serde_json::json!({"x": 1}));
353 }
354
355 #[tokio::test]
356 async fn in_memory_store_overwrite_keeps_latest() {
357 let store = InMemorySnapshotStore::new();
358 let stream = StreamId::new("process/abc");
359
360 store
361 .save(&Snapshot::new(
362 stream.clone(),
363 5,
364 1,
365 serde_json::json!({"seq": 5}),
366 ))
367 .await
368 .unwrap();
369 store
370 .save(&Snapshot::new(
371 stream.clone(),
372 10,
373 1,
374 serde_json::json!({"seq": 10}),
375 ))
376 .await
377 .unwrap();
378
379 let loaded = store
380 .load(&stream)
381 .await
382 .unwrap()
383 .expect("snapshot must exist");
384 assert_eq!(
385 loaded.sequence_number, 10,
386 "second save must overwrite first"
387 );
388 }
389
390 #[tokio::test]
391 async fn in_memory_store_separate_streams_isolated() {
392 let store = InMemorySnapshotStore::new();
393 let stream1 = StreamId::new("process/aaa");
394 let stream2 = StreamId::new("process/bbb");
395
396 store
397 .save(&Snapshot::new(
398 stream1.clone(),
399 3,
400 1,
401 serde_json::json!(null),
402 ))
403 .await
404 .unwrap();
405
406 assert!(
407 store.load(&stream2).await.unwrap().is_none(),
408 "unrelated stream must not return stream1's snapshot"
409 );
410 }
411
412 #[tokio::test]
413 async fn in_memory_store_is_empty_initially() {
414 let store = InMemorySnapshotStore::new();
415 assert!(store.is_empty().await);
416
417 let stream = StreamId::new("process/test");
418 store
419 .save(&Snapshot::new(stream, 1, 1, serde_json::json!({})))
420 .await
421 .unwrap();
422 assert!(!store.is_empty().await);
423 }
424
425 #[tokio::test]
426 async fn in_memory_store_clone_shares_data() {
427 let store1 = InMemorySnapshotStore::new();
428 let store2 = store1.clone();
429 let stream = StreamId::new("process/shared");
430
431 store1
432 .save(&Snapshot::new(
433 stream.clone(),
434 7,
435 1,
436 serde_json::json!({"y": 2}),
437 ))
438 .await
439 .unwrap();
440
441 let loaded = store2
442 .load(&stream)
443 .await
444 .unwrap()
445 .expect("clone must see the same snapshot");
446 assert_eq!(loaded.sequence_number, 7);
447 }
448}