Skip to main content

krabka_client_streams/store/
snapshot.rs

1//! Barrier snapshots: the frozen container, the storage seam, and the shared
2//! byte-store payload codec.
3//!
4//! A task snapshots every store it owns when a barrier fires. The result is a
5//! map from store name to opaque payload bytes, and the container below holds
6//! it. The container is frozen. `krabka-streams-rs`, `krabka-streams-java`, and
7//! `krabka-streams-go` write the same bytes:
8//!
9//! ```text
10//! version u32 = 1
11//! count   u32
12//! entries count x [ name_len u32 | name UTF-8 | value_len u32 | value bytes ]
13//! ```
14//!
15//! Every integer is big-endian. The entries are in ascending byte order of the
16//! name, so one snapshot always encodes to one byte string.
17//!
18//! The payload inside an entry is language-specific. Rust uses the byte-store
19//! payload that [`encode_byte_entries`] writes for every store over the
20//! pluggable byte backend, and a store with in-process state of its own writes
21//! its own payload.
22//!
23//! The cut identity is not in the container. It is the storage key, which is
24//! the task, the barrier group, and the epoch. See [`SnapshotKey`].
25
26use std::collections::BTreeMap;
27#[cfg(not(target_family = "wasm"))]
28use std::path::{Path, PathBuf};
29
30use async_trait::async_trait;
31use bytes::{BufMut, Bytes, BytesMut};
32
33use crate::{error::StreamsClientError, store::byte::ByteKeyValueStore};
34
35/// The container version that the three streams libraries share.
36pub const SNAPSHOT_CONTAINER_VERSION: u32 = 1;
37
38/// The version of the store payloads that Rust writes inside a container entry.
39const STORE_PAYLOAD_VERSION: u32 = 1;
40
41/// Every store of one task at one cut, as store name to payload bytes.
42///
43/// The map is ordered, and [`encode_snapshot`] writes the entries in that
44/// order. `BTreeMap<String, _>` orders by the UTF-8 bytes of the name, which is
45/// the order the frozen container needs.
46pub type TaskSnapshot = BTreeMap<String, Bytes>;
47
48/// Reads big-endian fields off a snapshot buffer and reports a short reason
49/// when the buffer runs out.
50///
51/// Every store that writes its own payload decodes it with this reader, so a
52/// truncated snapshot always reports the same way.
53pub(crate) struct SnapshotReader<'a> {
54    data: &'a [u8],
55}
56
57impl<'a> SnapshotReader<'a> {
58    pub(crate) fn new(data: &'a [u8]) -> Self {
59        Self { data }
60    }
61
62    pub(crate) fn is_empty(&self) -> bool {
63        self.data.is_empty()
64    }
65
66    fn take(&mut self, count: usize) -> Result<&'a [u8], StreamsClientError> {
67        let Some((head, rest)) = self.data.split_at_checked(count) else {
68            return Err(truncated());
69        };
70        self.data = rest;
71        Ok(head)
72    }
73
74    pub(crate) fn u8(&mut self) -> Result<u8, StreamsClientError> {
75        Ok(self.take(1)?[0])
76    }
77
78    pub(crate) fn u32(&mut self) -> Result<u32, StreamsClientError> {
79        let bytes = self.take(4)?;
80        Ok(u32::from_be_bytes(bytes.try_into().expect("four bytes")))
81    }
82
83    pub(crate) fn u64(&mut self) -> Result<u64, StreamsClientError> {
84        let bytes = self.take(8)?;
85        Ok(u64::from_be_bytes(bytes.try_into().expect("eight bytes")))
86    }
87
88    pub(crate) fn i64(&mut self) -> Result<i64, StreamsClientError> {
89        let bytes = self.take(8)?;
90        Ok(i64::from_be_bytes(bytes.try_into().expect("eight bytes")))
91    }
92
93    /// Reads a `u32` length and then that many bytes.
94    pub(crate) fn sized(&mut self) -> Result<Bytes, StreamsClientError> {
95        let length = self.u32()? as usize;
96        Ok(Bytes::copy_from_slice(self.take(length)?))
97    }
98
99    /// Fails when bytes remain after the last field.
100    pub(crate) fn finish(&self) -> Result<(), StreamsClientError> {
101        if self.is_empty() {
102            return Ok(());
103        }
104        Err(StreamsClientError::Snapshot(
105            "trailing bytes in state snapshot".to_owned(),
106        ))
107    }
108
109    /// Checks the leading version field of a store's own payload.
110    pub(crate) fn store_version(&mut self) -> Result<(), StreamsClientError> {
111        let version = self.u32()?;
112        if version == STORE_PAYLOAD_VERSION {
113            return Ok(());
114        }
115        Err(StreamsClientError::Snapshot(format!(
116            "unsupported store snapshot version {version}"
117        )))
118    }
119}
120
121fn truncated() -> StreamsClientError {
122    StreamsClientError::Snapshot("truncated state snapshot".to_owned())
123}
124
125/// Writes a `u32` length and then the bytes.
126///
127/// # Panics
128///
129/// Panics when `value` is longer than `u32::MAX` bytes.
130pub(crate) fn put_sized(buffer: &mut BytesMut, value: &[u8]) {
131    buffer.put_u32(u32::try_from(value.len()).expect("snapshot entry length fits u32"));
132    buffer.extend_from_slice(value);
133}
134
135/// Starts a store's own payload with the payload version.
136pub(crate) fn store_payload() -> BytesMut {
137    let mut buffer = BytesMut::new();
138    buffer.put_u32(STORE_PAYLOAD_VERSION);
139    buffer
140}
141
142/// Encodes a task snapshot into the frozen container bytes.
143///
144/// The entries come out in ascending byte order of the store name, so the same
145/// snapshot always gives the same bytes.
146///
147/// # Panics
148///
149/// Panics when a store name or a payload is longer than `u32::MAX` bytes.
150#[must_use]
151pub fn encode_snapshot(snapshot: &TaskSnapshot) -> Bytes {
152    let mut buffer = BytesMut::new();
153    buffer.put_u32(SNAPSHOT_CONTAINER_VERSION);
154    buffer.put_u32(u32::try_from(snapshot.len()).expect("snapshot entry count fits u32"));
155    for (name, value) in snapshot {
156        put_sized(&mut buffer, name.as_bytes());
157        put_sized(&mut buffer, value);
158    }
159    buffer.freeze()
160}
161
162/// Decodes the frozen container bytes back into a task snapshot.
163///
164/// # Errors
165///
166/// Returns [`StreamsClientError::Snapshot`] when the version is not
167/// [`SNAPSHOT_CONTAINER_VERSION`], when a length runs past the end of the
168/// buffer, when a name is not UTF-8, or when bytes remain after the last entry.
169pub fn decode_snapshot(data: &[u8]) -> Result<TaskSnapshot, StreamsClientError> {
170    let mut reader = SnapshotReader::new(data);
171    let version = reader.u32()?;
172    if version != SNAPSHOT_CONTAINER_VERSION {
173        return Err(StreamsClientError::Snapshot(format!(
174            "unsupported state snapshot version {version}"
175        )));
176    }
177    let count = reader.u32()?;
178    let mut snapshot = TaskSnapshot::new();
179    for _ in 0..count {
180        let name = reader.sized()?;
181        let name = String::from_utf8(name.to_vec())
182            .map_err(|_error| StreamsClientError::Snapshot("non-UTF-8 store name".to_owned()))?;
183        snapshot.insert(name, reader.sized()?);
184    }
185    reader.finish()?;
186    Ok(snapshot)
187}
188
189/// Encodes the entries of a byte-backed store as one entry payload.
190///
191/// The layout is a `u32` version of `1`, a `u32` entry count, and then per
192/// entry a `u32` key length, the key, a `u32` value length, and the value. The
193/// payload is Rust's own, so no other language reads it.
194///
195/// # Panics
196///
197/// Panics when a key or a value is longer than `u32::MAX` bytes.
198pub(crate) fn encode_byte_entries(entries: &[(Bytes, Bytes)]) -> Bytes {
199    let mut buffer = store_payload();
200    put_byte_entries(&mut buffer, entries);
201    buffer.freeze()
202}
203
204/// Writes a count-prefixed run of key-value entries into a store payload.
205///
206/// A store that also carries scalars writes them first and then calls this
207/// function, so every byte-backed store shares one entry layout.
208///
209/// # Panics
210///
211/// Panics when there are more than `u32::MAX` entries.
212pub(crate) fn put_byte_entries(buffer: &mut BytesMut, entries: &[(Bytes, Bytes)]) {
213    buffer.put_u32(u32::try_from(entries.len()).expect("entry count fits u32"));
214    for (key, value) in entries {
215        put_sized(buffer, key);
216        put_sized(buffer, value);
217    }
218}
219
220/// Decodes the payload that [`encode_byte_entries`] wrote.
221pub(crate) fn decode_byte_entries(data: &[u8]) -> Result<Vec<(Bytes, Bytes)>, StreamsClientError> {
222    let mut reader = SnapshotReader::new(data);
223    reader.store_version()?;
224    let entries = read_byte_entries(&mut reader)?;
225    reader.finish()?;
226    Ok(entries)
227}
228
229/// Reads a count-prefixed run of key-value entries out of a store payload.
230pub(crate) fn read_byte_entries(
231    reader: &mut SnapshotReader<'_>,
232) -> Result<Vec<(Bytes, Bytes)>, StreamsClientError> {
233    let count = reader.u32()?;
234    let mut entries = Vec::new();
235    for _ in 0..count {
236        let key = reader.sized()?;
237        entries.push((key, reader.sized()?));
238    }
239    Ok(entries)
240}
241
242/// Snapshots every entry of a byte-backed store.
243///
244/// Each store over the pluggable byte backend delegates its
245/// [`StateStore::snapshot`](crate::store::api::StateStore::snapshot) to this
246/// function, so all of them share one payload format.
247pub(crate) async fn snapshot_byte_store(store: &dyn ByteKeyValueStore) -> Bytes {
248    encode_byte_entries(&store.scan_all().await)
249}
250
251/// Replaces every entry of a byte-backed store with a snapshot payload.
252///
253/// The store is wiped first, so the restored state is the snapshot and nothing
254/// else. A malformed payload leaves the store untouched, because the decode
255/// runs before the wipe.
256pub(crate) async fn restore_byte_store(
257    store: &mut dyn ByteKeyValueStore,
258    data: &[u8],
259) -> Result<(), StreamsClientError> {
260    let entries = decode_byte_entries(data)?;
261    store.clear().await;
262    for (key, value) in entries {
263        store.put(key, value).await;
264    }
265    Ok(())
266}
267
268/// Names one stored snapshot: the task, the barrier group, and the epoch.
269///
270/// The cut identity lives here and not in the container, so two barrier groups
271/// and two epochs never share a snapshot.
272#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
273pub struct SnapshotKey {
274    /// The task the snapshot belongs to, as `<subtopology>-<partition>`.
275    pub task: String,
276    /// The barrier group whose cut the task aligned on.
277    pub group: String,
278    /// The epoch of that cut.
279    pub epoch: i64,
280}
281
282impl SnapshotKey {
283    /// Builds a key for one task, group, and epoch.
284    #[must_use]
285    pub fn new(task: impl Into<String>, group: impl Into<String>, epoch: i64) -> Self {
286        Self {
287            task: task.into(),
288            group: group.into(),
289            epoch,
290        }
291    }
292
293    /// The file name that the file-backed snapshot store gives this key.
294    ///
295    /// Every character outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` becomes an
296    /// underscore, so a group name or a task name can never escape the
297    /// directory.
298    #[must_use]
299    pub fn file_name(&self) -> String {
300        format!(
301            "{}-{}-epoch-{}.snapshot",
302            sanitize(&self.group),
303            sanitize(&self.task),
304            self.epoch
305        )
306    }
307}
308
309fn sanitize(value: &str) -> String {
310    value
311        .chars()
312        .map(|c| {
313            if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
314                c
315            } else {
316                '_'
317            }
318        })
319        .collect()
320}
321
322/// Keeps the store snapshots a task takes at each barrier cut.
323///
324/// `FileSnapshotStore` writes them to local files on native targets.
325/// [`NoSnapshotStore`] keeps nothing, which makes the barrier an alignment and
326/// commit point with no durable state.
327#[async_trait]
328pub trait SnapshotStore: Send + Sync + 'static {
329    /// Stores one task's snapshot under `key`, and replaces what was there.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`StreamsClientError::Snapshot`] when the implementation cannot
334    /// write the snapshot.
335    async fn save(
336        &self,
337        key: &SnapshotKey,
338        snapshot: &TaskSnapshot,
339    ) -> Result<(), StreamsClientError>;
340
341    /// Reads back the snapshot stored under `key`, or `None` when there is
342    /// none.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`StreamsClientError::Snapshot`] when the implementation cannot
347    /// read the snapshot, or when the stored bytes are malformed.
348    async fn load(&self, key: &SnapshotKey) -> Result<Option<TaskSnapshot>, StreamsClientError>;
349}
350
351/// A snapshot store that keeps nothing.
352///
353/// Every save is discarded and every load is empty. Use it when a barrier only
354/// has to align and commit, and no cut has to be restorable.
355#[derive(Debug, Default, Clone, Copy)]
356pub struct NoSnapshotStore;
357
358#[async_trait]
359impl SnapshotStore for NoSnapshotStore {
360    async fn save(
361        &self,
362        _key: &SnapshotKey,
363        _snapshot: &TaskSnapshot,
364    ) -> Result<(), StreamsClientError> {
365        Ok(())
366    }
367
368    async fn load(&self, _key: &SnapshotKey) -> Result<Option<TaskSnapshot>, StreamsClientError> {
369        Ok(None)
370    }
371}
372
373/// Writes each snapshot to one file under a directory.
374///
375/// A save writes a temporary file in the same directory and renames it over the
376/// target, so a crash in the middle of a save leaves the previous snapshot
377/// whole. The directory is created on the first save. The store needs a file
378/// system and a blocking thread pool, so it exists only on native targets.
379///
380/// # Examples
381///
382/// ```
383/// use krabka_client_streams::store::snapshot::{FileSnapshotStore, SnapshotKey};
384///
385/// let store = FileSnapshotStore::new(std::env::temp_dir().join("krabka-snapshots"));
386/// // transactions-0-1-epoch-7.snapshot
387/// let file = store.file(&SnapshotKey::new("0-1", "transactions", 7));
388/// ```
389#[cfg(not(target_family = "wasm"))]
390#[derive(Debug, Clone)]
391pub struct FileSnapshotStore {
392    directory: PathBuf,
393}
394
395#[cfg(not(target_family = "wasm"))]
396impl FileSnapshotStore {
397    /// Creates a store rooted at `directory`.
398    #[must_use]
399    pub fn new(directory: impl Into<PathBuf>) -> Self {
400        Self {
401            directory: directory.into(),
402        }
403    }
404
405    /// The file that holds one key's snapshot.
406    #[must_use]
407    pub fn file(&self, key: &SnapshotKey) -> PathBuf {
408        self.directory.join(key.file_name())
409    }
410}
411
412/// Writes `data` to a temporary file beside `target` and renames it over
413/// `target`.
414#[cfg(not(target_family = "wasm"))]
415fn write_atomically(directory: &Path, target: &Path, data: &[u8]) -> std::io::Result<()> {
416    std::fs::create_dir_all(directory)?;
417    let mut temporary = target.as_os_str().to_owned();
418    temporary.push(".tmp");
419    let temporary = PathBuf::from(temporary);
420    std::fs::write(&temporary, data)?;
421    match std::fs::rename(&temporary, target) {
422        Ok(()) => Ok(()),
423        Err(error) => {
424            let _ = std::fs::remove_file(&temporary);
425            Err(error)
426        }
427    }
428}
429
430#[cfg(not(target_family = "wasm"))]
431#[async_trait]
432impl SnapshotStore for FileSnapshotStore {
433    async fn save(
434        &self,
435        key: &SnapshotKey,
436        snapshot: &TaskSnapshot,
437    ) -> Result<(), StreamsClientError> {
438        let directory = self.directory.clone();
439        let target = self.file(key);
440        let data = encode_snapshot(snapshot);
441        let task = key.task.clone();
442        tokio::task::spawn_blocking(move || write_atomically(&directory, &target, &data))
443            .await
444            .map_err(|error| StreamsClientError::Snapshot(error.to_string()))?
445            .map_err(|error| {
446                StreamsClientError::Snapshot(format!("cannot save task {task} state: {error}"))
447            })
448    }
449
450    async fn load(&self, key: &SnapshotKey) -> Result<Option<TaskSnapshot>, StreamsClientError> {
451        let target = self.file(key);
452        let task = key.task.clone();
453        let read = tokio::task::spawn_blocking(move || std::fs::read(&target))
454            .await
455            .map_err(|error| StreamsClientError::Snapshot(error.to_string()))?;
456        match read {
457            Ok(data) => decode_snapshot(&data).map(Some),
458            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
459            Err(error) => Err(StreamsClientError::Snapshot(format!(
460                "cannot load task {task} state: {error}"
461            ))),
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use assert2::{assert, check};
469
470    use super::*;
471
472    fn snapshot(entries: &[(&str, &[u8])]) -> TaskSnapshot {
473        entries
474            .iter()
475            .map(|(name, value)| ((*name).to_owned(), Bytes::copy_from_slice(value)))
476            .collect()
477    }
478
479    #[test]
480    fn container_bytes_match_the_frozen_layout() {
481        let encoded = encode_snapshot(&snapshot(&[("b", b"22"), ("a", b"1")]));
482        // version 1, count 2, then "a" before "b" whatever the insert order was.
483        let expected: Vec<u8> = [
484            &1u32.to_be_bytes()[..],
485            &2u32.to_be_bytes()[..],
486            &1u32.to_be_bytes()[..],
487            b"a",
488            &1u32.to_be_bytes()[..],
489            b"1",
490            &1u32.to_be_bytes()[..],
491            b"b",
492            &2u32.to_be_bytes()[..],
493            b"22",
494        ]
495        .concat();
496        check!(encoded.as_ref() == expected.as_slice());
497    }
498
499    #[test]
500    fn container_round_trips_and_rejects_malformed_bytes() {
501        let original = snapshot(&[("counts", b"\x00\x01"), ("windows", b"")]);
502        let encoded = encode_snapshot(&original);
503        check!(decode_snapshot(&encoded).unwrap() == original);
504
505        let mut wrong_version = encoded.to_vec();
506        wrong_version[3] = 9;
507        assert!(let Err(error) = decode_snapshot(&wrong_version));
508        check!(
509            error
510                .to_string()
511                .contains("unsupported state snapshot version")
512        );
513
514        let truncated = &encoded[..encoded.len() - 1];
515        assert!(let Err(error) = decode_snapshot(truncated));
516        check!(error.to_string().contains("truncated"));
517
518        let mut trailing = encoded.to_vec();
519        trailing.push(0);
520        assert!(let Err(error) = decode_snapshot(&trailing));
521        check!(error.to_string().contains("trailing bytes"));
522    }
523
524    #[test]
525    fn byte_entries_round_trip_and_reject_malformed_bytes() {
526        let entries = vec![
527            (Bytes::from_static(b"k1"), Bytes::from_static(b"v1")),
528            (Bytes::from_static(b"k2"), Bytes::from_static(b"")),
529        ];
530        let encoded = encode_byte_entries(&entries);
531        check!(decode_byte_entries(&encoded).unwrap() == entries);
532        assert!(let Err(error) = decode_byte_entries(&encoded[..5]));
533        check!(error.to_string().contains("truncated"));
534    }
535
536    #[test]
537    fn snapshot_key_file_name_sanitizes_separators() {
538        let key = SnapshotKey::new("0/1", "../escape", 3);
539        check!(key.file_name() == ".._escape-0_1-epoch-3.snapshot");
540    }
541
542    #[tokio::test]
543    async fn file_store_saves_loads_and_reports_a_missing_snapshot() {
544        let directory = tempfile::tempdir().unwrap();
545        let store = FileSnapshotStore::new(directory.path());
546        let key = SnapshotKey::new("0-0", "transactions", 4);
547        check!(store.load(&key).await.unwrap() == None);
548
549        let original = snapshot(&[("counts", b"\x07")]);
550        store.save(&key, &original).await.unwrap();
551        check!(store.load(&key).await.unwrap() == Some(original.clone()));
552
553        // A second save replaces the file rather than appending to it.
554        let replacement = snapshot(&[("counts", b"\x09"), ("other", b"x")]);
555        store.save(&key, &replacement).await.unwrap();
556        check!(store.load(&key).await.unwrap() == Some(replacement));
557
558        // Another epoch is another file.
559        check!(
560            store
561                .load(&SnapshotKey::new("0-0", "transactions", 5))
562                .await
563                .unwrap()
564                == None
565        );
566    }
567
568    #[tokio::test]
569    async fn no_snapshot_store_keeps_nothing() {
570        let store = NoSnapshotStore;
571        let key = SnapshotKey::new("0-0", "g", 1);
572        store.save(&key, &snapshot(&[("s", b"v")])).await.unwrap();
573        check!(store.load(&key).await.unwrap() == None);
574    }
575
576    #[tokio::test]
577    async fn byte_store_snapshot_replaces_every_entry() {
578        use crate::store::byte::InMemoryBytes;
579
580        let mut source = InMemoryBytes::default();
581        source
582            .put(Bytes::from_static(b"a"), Bytes::from_static(b"1"))
583            .await;
584        source
585            .put(Bytes::from_static(b"b"), Bytes::from_static(b"2"))
586            .await;
587        let payload = snapshot_byte_store(&source).await;
588
589        let mut target = InMemoryBytes::default();
590        target
591            .put(Bytes::from_static(b"stale"), Bytes::from_static(b"x"))
592            .await;
593        restore_byte_store(&mut target, &payload).await.unwrap();
594        check!(target.scan_all().await == source.scan_all().await);
595    }
596
597    // ─── every store kind ─────────────────────────────────────────────────────
598
599    /// Rewinds a store to `at_cut` and checks it is back where it was, byte for
600    /// byte.
601    ///
602    /// `after` is the payload of the state the store reached past the cut. It
603    /// must differ, or the round trip would prove nothing.
604    async fn rewinds_to_its_cut(store: &mut dyn crate::store::api::StateStore, at_cut: &Bytes) {
605        let after = store.snapshot().await;
606        check!(&after != at_cut, "the store must move past the cut");
607        store.restore_snapshot(at_cut.clone()).await.unwrap();
608        check!(&store.snapshot().await == at_cut);
609    }
610
611    fn string_serde() -> Box<dyn crate::processor::serde::Serde<String>> {
612        Box::new(crate::processor::serde::StringSerde)
613    }
614
615    fn i64_serde() -> Box<dyn crate::processor::serde::Serde<i64>> {
616        Box::new(crate::processor::serde::I64Serde)
617    }
618
619    #[tokio::test]
620    async fn key_value_store_rewinds_to_its_cut() {
621        use crate::store::{api::KeyValueStore, kv::KeyValueBytesStore};
622
623        let mut store = KeyValueBytesStore::<String, i64>::in_memory(
624            "counts".into(),
625            string_serde(),
626            i64_serde(),
627            "counts-changelog".into(),
628        );
629        store.put("a".into(), 1).await;
630        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
631        store.put("a".into(), 2).await;
632        store.put("b".into(), 9).await;
633        rewinds_to_its_cut(&mut store, &at_cut).await;
634        check!(store.get(&"a".to_string()).await == Some(1));
635        check!(store.get(&"b".to_string()).await == None);
636    }
637
638    #[tokio::test]
639    async fn window_store_rewinds_to_its_cut() {
640        use krabka_units::prelude::*;
641
642        use crate::store::window::{WindowBytesStore, WindowStore};
643
644        let mut store = WindowBytesStore::<String, i64>::in_memory(
645            "windows".into(),
646            string_serde(),
647            i64_serde(),
648            "windows-changelog".into(),
649            millis(100),
650        );
651        store.put("a".into(), 0, 1, 5).await;
652        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
653        store.put("a".into(), 100, 7, 105).await;
654        rewinds_to_its_cut(&mut store, &at_cut).await;
655        check!(store.fetch_single(&"a".to_string(), 100).await == None);
656    }
657
658    #[tokio::test]
659    async fn session_store_rewinds_to_its_cut() {
660        use crate::store::session::{SessionBytesStore, SessionStore};
661
662        let mut store = SessionBytesStore::<String, i64>::in_memory(
663            "sessions".into(),
664            string_serde(),
665            i64_serde(),
666            "sessions-changelog".into(),
667        );
668        store.put("a".into(), 0, 10, 1).await;
669        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
670        store.put("a".into(), 20, 30, 2).await;
671        rewinds_to_its_cut(&mut store, &at_cut).await;
672        check!(store.find_sessions(&"a".to_string(), 0, 100).await == vec![(0, 10, 1)]);
673    }
674
675    #[tokio::test]
676    async fn join_window_store_rewinds_to_its_cut_with_its_seqnum() {
677        use crate::store::join_window::{JoinWindowBytesStore, JoinWindowStore};
678
679        let mut store = JoinWindowBytesStore::<String, i64>::in_memory(
680            "joins".into(),
681            string_serde(),
682            i64_serde(),
683            "joins-changelog".into(),
684        );
685        store.put("a".into(), 10, 1).await;
686        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
687        store.put("a".into(), 10, 2).await;
688        rewinds_to_its_cut(&mut store, &at_cut).await;
689        // The duplicate at the same timestamp is gone, and the next put reuses
690        // the sequence number the cut recorded.
691        check!(store.fetch(&"a".to_string(), 0, 100).await == vec![(10, 1)]);
692        store.put("a".into(), 10, 3).await;
693        check!(store.fetch(&"a".to_string(), 0, 100).await == vec![(10, 1), (10, 3)]);
694    }
695
696    #[tokio::test]
697    async fn versioned_store_rewinds_to_its_cut_with_its_stream_time() {
698        use krabka_units::prelude::*;
699
700        use crate::store::versioned::{VersionedBytesStore, VersionedKeyValueStore};
701
702        let mut store = VersionedBytesStore::<String, i64>::in_memory(
703            "history".into(),
704            secs(1_000),
705            string_serde(),
706            i64_serde(),
707            "history-changelog".into(),
708        );
709        store.put("a".into(), Some(1), 10).await;
710        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
711        store.put("a".into(), Some(2), 20).await;
712        rewinds_to_its_cut(&mut store, &at_cut).await;
713        check!(store.get(&"a".to_string()).await.map(|r| r.value) == Some(1));
714        // The observed stream time came back with the chains, so a version below
715        // the restored horizon is still placed by the same rule.
716        check!(store.get_as_of(&"a".to_string(), 20).await.map(|r| r.value) == Some(1));
717    }
718
719    #[tokio::test]
720    async fn suppress_store_rewinds_to_its_cut_with_its_counters() {
721        use crate::{
722            dsl::processors::change::Change,
723            store::{
724                suppress_bufval::SuppressRecordCtx,
725                suppress_store::{SuppressBytesStore, SuppressStore},
726            },
727        };
728
729        let context = |timestamp| SuppressRecordCtx {
730            topic: "in".to_owned(),
731            partition: 0,
732            offset: 0,
733            timestamp,
734        };
735        let mut store = SuppressBytesStore::<String, i64>::in_memory(
736            "buffer".into(),
737            string_serde(),
738            i64_serde(),
739            "buffer-changelog".into(),
740        );
741        store
742            .put(
743                "a".into(),
744                10,
745                Change {
746                    old: None,
747                    new: Some(1),
748                },
749                context(10),
750            )
751            .await;
752        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
753        let size_at_cut = store.byte_size();
754        store
755            .put(
756                "b".into(),
757                20,
758                Change {
759                    old: None,
760                    new: Some(2),
761                },
762                context(20),
763            )
764            .await;
765        rewinds_to_its_cut(&mut store, &at_cut).await;
766        check!(store.len() == 1);
767        check!(store.byte_size() == size_at_cut);
768        let evicted = store.evict_while(100).await;
769        check!(
770            evicted
771                == vec![(
772                    "a".to_string(),
773                    Change {
774                        old: None,
775                        new: Some(1)
776                    },
777                    10
778                )]
779        );
780    }
781
782    #[tokio::test]
783    async fn join_grace_buffer_rewinds_to_its_cut_with_its_sequence() {
784        use crate::store::join_grace_buffer::JoinGraceBufferStore;
785
786        let mut store = JoinGraceBufferStore::<String, i64>::in_memory(
787            "grace".into(),
788            string_serde(),
789            i64_serde(),
790            "grace-changelog".into(),
791        );
792        store.put("a".into(), 1, 100).await;
793        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
794        store.put("b".into(), 2, 200).await;
795        rewinds_to_its_cut(&mut store, &at_cut).await;
796        check!(store.len() == 1);
797        // The restored sequence keeps a later put behind the recovered entry at
798        // the same timestamp.
799        store.put("c".into(), 3, 100).await;
800        check!(
801            store.drain_due(100).await
802                == vec![("a".to_string(), 1, 100), ("c".to_string(), 3, 100)]
803        );
804    }
805
806    #[tokio::test]
807    async fn subscription_store_rewinds_to_its_cut() {
808        use crate::{
809            dsl::processors::fk::subscription::{Instruction, SubscriptionWrapper},
810            store::fk_subscription::SubscriptionBytesStore,
811        };
812
813        let wrapper = |pk: &str| SubscriptionWrapper {
814            instruction: Instruction::PropagateOnlyIfFkValAvailable,
815            hash: Some(vec![7u8; 16]),
816            primary_key: Bytes::copy_from_slice(pk.as_bytes()),
817            primary_partition: 0,
818        };
819        let mut store = SubscriptionBytesStore::in_memory("subs".into(), "subs-changelog".into());
820        store.put(b"FK1", b"pk1", &wrapper("pk1"), 10).await;
821        let at_cut = crate::store::api::StateStore::snapshot(&mut store).await;
822        store.put(b"FK1", b"pk2", &wrapper("pk2"), 11).await;
823        rewinds_to_its_cut(&mut store, &at_cut).await;
824        check!(store.range_by_foreign(b"FK1").await.len() == 1);
825    }
826
827    /// A store whose payload is malformed rejects the restore instead of
828    /// half-applying it.
829    #[tokio::test]
830    async fn a_malformed_payload_is_rejected() {
831        use crate::store::{api::StateStore, kv::KeyValueBytesStore};
832
833        let mut store = KeyValueBytesStore::<String, i64>::in_memory(
834            "counts".into(),
835            string_serde(),
836            i64_serde(),
837            "counts-changelog".into(),
838        );
839        assert!(let Err(error) = store.restore_snapshot(Bytes::from_static(b"\x00")).await);
840        check!(error.to_string().contains("truncated state snapshot"));
841    }
842}