1use 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
35pub const SNAPSHOT_CONTAINER_VERSION: u32 = 1;
37
38const STORE_PAYLOAD_VERSION: u32 = 1;
40
41pub type TaskSnapshot = BTreeMap<String, Bytes>;
47
48pub(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 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 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 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
125pub(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
135pub(crate) fn store_payload() -> BytesMut {
137 let mut buffer = BytesMut::new();
138 buffer.put_u32(STORE_PAYLOAD_VERSION);
139 buffer
140}
141
142#[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
162pub 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
189pub(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
204pub(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
220pub(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
229pub(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
242pub(crate) async fn snapshot_byte_store(store: &dyn ByteKeyValueStore) -> Bytes {
248 encode_byte_entries(&store.scan_all().await)
249}
250
251pub(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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
273pub struct SnapshotKey {
274 pub task: String,
276 pub group: String,
278 pub epoch: i64,
280}
281
282impl SnapshotKey {
283 #[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 #[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#[async_trait]
328pub trait SnapshotStore: Send + Sync + 'static {
329 async fn save(
336 &self,
337 key: &SnapshotKey,
338 snapshot: &TaskSnapshot,
339 ) -> Result<(), StreamsClientError>;
340
341 async fn load(&self, key: &SnapshotKey) -> Result<Option<TaskSnapshot>, StreamsClientError>;
349}
350
351#[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#[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 #[must_use]
399 pub fn new(directory: impl Into<PathBuf>) -> Self {
400 Self {
401 directory: directory.into(),
402 }
403 }
404
405 #[must_use]
407 pub fn file(&self, key: &SnapshotKey) -> PathBuf {
408 self.directory.join(key.file_name())
409 }
410}
411
412#[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 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 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 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 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 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 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 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 #[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}