Skip to main content

kevy_store/
snapshot.rs

1//! Point-in-time snapshot views — the freeze half of COW serialization.
2//!
3//! [`Store::collect_snapshot`] walks the keyspace once and shallow-clones
4//! every live entry: keys and string values copy their bytes (≤22 B inline
5//! = a 24 B memcpy), collection values bump an `Arc` refcount. The pause is
6//! O(n) at nanoseconds per entry — independent of collection sizes and of
7//! disk speed. The returned [`SnapshotView`] is `Send`: hand it to a
8//! background thread and serialize at leisure while the store keeps
9//! mutating (writes copy-on-write via `Arc::make_mut`, deletions just drop
10//! one strong ref — the view's data stays alive until it is dropped).
11//!
12//! TTLs are resolved to remaining-milliseconds at collect time, so the view
13//! is a consistent instant: an entry that expires *after* the collect still
14//! appears with the remaining TTL it had at that instant.
15
16#[cfg(not(feature = "std"))]
17use crate::nostd_prelude::*;
18use crate::value::Value;
19use crate::{SmallBytes, Store, now_ns, remaining_ms};
20
21/// A frozen, `Send` view of one store's live entries at a single instant.
22pub struct SnapshotView {
23    entries: Vec<(SmallBytes, Value, Option<u64>)>,
24    /// Hash field TTLs frozen with the view.
25    hfttl: Vec<(SmallBytes, SmallBytes, u64)>,
26    /// Tiering view pinning: every vlog file that existed at
27    /// collect time. A cold stub cloned into the view can only
28    /// reference these, and a pinned file survives compaction until the
29    /// last Arc drops — so the view's offsets stay valid for its whole
30    /// life, however long the serializer thread takes.
31    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
32    pins: Vec<std::sync::Arc<kevy_vlog::VlogFile>>,
33    /// Row-segment pins — the seg-backed stubs' serializer-thread
34    /// read path, same doctrine as the vlog pins.
35    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
36    seg_pins: Vec<(u32, std::sync::Arc<kevy_seg::Seg>)>,
37    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
38    seg_files: Vec<(u32, String)>,
39}
40
41// Compile-time guarantee that a view can cross to a serializer thread.
42const _: () = {
43    const fn assert_send<T: Send>() {}
44    assert_send::<SnapshotView>();
45};
46
47impl SnapshotView {
48    /// Visit every entry as `(key, &value, ttl_ms)` — the same shape as
49    /// [`Store::snapshot_each`], so serializers take either source.
50    pub fn each<F: FnMut(&[u8], &Value, Option<u64>)>(&self, mut f: F) {
51        for (k, v, ttl) in &self.entries {
52            f(k.as_slice(), v, *ttl);
53        }
54    }
55
56    /// Visit the frozen hash field TTLs.
57    pub fn each_hash_ttl<F: FnMut(&[u8], &[u8], u64)>(&self, mut f: F) {
58        for (k, field, d) in &self.hfttl {
59            f(k.as_slice(), field.as_slice(), *d);
60        }
61    }
62
63    /// Number of entries frozen in the view.
64    pub fn len(&self) -> usize {
65        self.entries.len()
66    }
67
68    /// Whether the view holds zero entries.
69    pub fn is_empty(&self) -> bool {
70        self.entries.is_empty()
71    }
72
73    /// Decode a cold stub's record against the view's pinned vlog files
74    /// into a fresh hot [`Value`] — the serializer-thread read path:
75    /// no store access, no promotion, memory bound = this one value.
76    /// `None` when `v` is hot. A stub naming an unpinned file, a failed
77    /// read, or a bad decode is a process bug (the vlog is per-boot and
78    /// this process pinned every file at collect time) — surfaced
79    /// loudly, never healed silently.
80    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
81    pub fn materialize_cold(&self, key: &[u8], v: &Value) -> Option<Value> {
82        let Value::Cold(c) = v else { return None };
83        if c.is_seg() {
84            let payload = self
85                .seg_pins
86                .iter()
87                .find(|(q, _)| *q == c.seg_ix())
88                .expect("segrows: view stub references a segment pinned at collect time")
89                .1
90                .get(key)
91                .expect("segrows: pinned segment read failed — refused, not healed")
92                .expect("segrows: stub points at a record the segment does not hold");
93            return Some(
94                crate::tier_codec::decode(c.type_tag, payload)
95                    .expect("segrows: cold row decode failed — process bug"),
96            );
97        }
98        let file = self
99            .pins
100            .iter()
101            .find(|f| f.id() == c.file_id)
102            .expect("tier: view stub references a file pinned at collect time");
103        let (_key, payload) = file
104            .read(c.vref())
105            .expect("tier: pinned vlog read failed — per-boot spill file, this is a process bug");
106        Some(
107            crate::tier_codec::decode(c.type_tag, payload)
108                .expect("tier: cold record decode failed — process bug"),
109        )
110    }
111
112    /// No tier backend on this target — `Value::Cold` cannot exist.
113    #[cfg(not(all(feature = "std", not(target_arch = "wasm32"))))]
114    pub fn materialize_cold(&self, _key: &[u8], _v: &Value) -> Option<Value> {
115        None
116    }
117
118    /// The frozen row segments' `(seq, file)` identities.
119    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
120    pub fn row_seg_files(&self) -> Vec<(u32, String)> {
121        self.seg_files.clone()
122    }
123    /// The frozen row segments' `(seq, file)` identities — always empty
124    /// off std, where there are no segment files to name. The std twin
125    /// above is the one that answers.
126    #[cfg(not(all(feature = "std", not(target_arch = "wasm32"))))]
127    pub fn row_seg_files(&self) -> Vec<(u32, String)> {
128        Vec::new()
129    }
130}
131
132impl Store {
133    /// Freeze a point-in-time [`SnapshotView`] of every live entry.
134    ///
135    /// O(n) shallow: per entry one key clone + one [`Value`] clone (string
136    /// bytes copied, collections refcount-bumped) + the TTL resolved to
137    /// remaining millis. Expired-but-unreaped entries are skipped, matching
138    /// [`Store::snapshot_each`].
139    pub fn collect_snapshot(&self) -> SnapshotView {
140        let now = now_ns();
141        let mut entries = Vec::with_capacity(self.map.len());
142        for (k, e) in &self.map {
143            if e.is_expired_at(now) {
144                continue;
145            }
146            let ttl = e.expire_at_ns.map(|ns| remaining_ms(ns, now));
147            entries.push((k.clone(), e.value.clone(), ttl));
148        }
149        let mut hfttl = Vec::new();
150        self.hash_ttl_each(|k, f, d| {
151            hfttl.push((crate::SmallBytes::from_slice(k), crate::SmallBytes::from_slice(f), d));
152        });
153        SnapshotView {
154            entries,
155            hfttl,
156            // View pinning: capture ALL current vlog file pins with
157            // the view — the frozen stubs above can only reference
158            // files that exist at this instant.
159            #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
160            pins: self.tier_pins(),
161            #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
162            seg_pins: self.segrow_pins(),
163            #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
164            seg_files: self.row_seg_files(),
165        }
166    }
167}