Skip to main content

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}