Skip to main content

kevy_store/
segrows.rs

1//! Row segments — the persistent second backing behind `Value::Cold`.
2//! A windowed table's out-of-window hash rows phase-change in place:
3//! the key, the Entry and its TTL stay hot, the value becomes a stub
4//! whose backing is an immutable segment file instead of the per-boot
5//! vlog. Reads resolve through the same stub seam as the value tier;
6//! a write promotes-then-writes; a replaced stub strands the segment
7//! record for compaction (no tombstones — reads never reach a
8//! stranded record, the hot stub is the only path in).
9//!
10//! Row segments are persistent truth: `enable_seg_rows` loads the
11//! manifest's registered segments (keyed by the monotone seq embedded
12//! in the file name — the stub's stable identity across restarts),
13//! the AOF's `SEGMENTED` frame re-establishes each row's stub at
14//! replay (demote-or-insert), and segments nothing references after
15//! replay are swept as orphans (a crash between sealing and the frame
16//! leaves exactly that). The stub snapshot record and the rewrite
17//! frame — the parts that stop snapshots/rewrites carrying cold row
18//! data — are the next train; until then both still materialize.
19
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23use crate::value::{COLD_TAG_HASH, ColdRef, Value};
24use crate::{Store, key_heap_bytes_for, tier_codec};
25
26/// One shard's row-segment directory: the open segments and their
27/// live/dead record accounting (compaction's future trigger feed).
28pub(crate) struct SegRows {
29    dir: PathBuf,
30    /// Open segments, keyed by their stable seq (the file-name number
31    /// a stub's `seg_ix` refers to — Vec-of-pairs, segment counts are
32    /// small and lookups linear).
33    segs: Vec<(u32, SegSlot)>,
34    seq: u32,
35}
36
37struct SegSlot {
38    /// Arc so a [`crate::SnapshotView`] can pin the segment across the
39    /// serializer thread, exactly like the vlog file pins.
40    seg: Arc<kevy_seg::Seg>,
41    file: String,
42    live: u64,
43    dead: u64,
44}
45
46impl SegRows {
47    fn slot(&self, seq: u32) -> &SegSlot {
48        &self.segs.iter().find(|(q, _)| *q == seq).expect("stub names a loaded segment").1
49    }
50
51    fn slot_mut(&mut self, seq: u32) -> Option<&mut SegSlot> {
52        self.segs.iter_mut().find(|(q, _)| *q == seq).map(|(_, s)| s)
53    }
54}
55
56/// One sealed eviction batch: the segment's identity and EXACTLY the
57/// keys it holds (the commit's phase-change list).
58pub struct SealedRows {
59    /// The segment's stable seq.
60    pub seq: u32,
61    /// Its file name — what the SEGMENTED frame carries.
62    pub file: String,
63    keys: Vec<Vec<u8>>,
64}
65
66/// The manifest meta tag row segments register under.
67const ROW_TAG: &[u8] = b"rowcold:";
68
69impl ColdRef {
70    /// A stub pointing into the row-segment directory.
71    pub(crate) fn seg(seq: u32, weight: u32, _type_tag: u8) -> Self {
72        ColdRef::from_seg_parts(seq, weight)
73    }
74
75    /// Whether this stub's backing is a row segment (vs the vlog).
76    pub(crate) fn is_seg(self) -> bool {
77        self.seg_parts().is_some()
78    }
79
80    /// The segment's stable seq (seg backing only).
81    pub(crate) fn seg_ix(self) -> u32 {
82        self.offset as u32
83    }
84}
85
86impl SegRows {
87    fn read(&self, cref: ColdRef, key: &[u8]) -> Value {
88        let slot = self.slot(cref.seg_ix());
89        let payload = slot
90            .seg
91            .get(key)
92            .expect("segrows: segment read failed — refused, not healed")
93            .unwrap_or_else(|| {
94                panic!(
95                    "segrows: stub for {:?} points at segment '{}' (seq {}) which does not hold it",
96                    String::from_utf8_lossy(key),
97                    slot.file,
98                    cref.seg_ix(),
99                )
100            });
101        tier_codec::decode(cref.type_tag, payload)
102            .expect("segrows: cold row decode failed — process bug")
103    }
104}
105
106impl Store {
107    /// Turn row segments on for this shard, rooted at `dir`, loading
108    /// every manifest-registered row segment from the previous run —
109    /// they are truth, and the AOF's SEGMENTED frames (or a stub
110    /// snapshot) will reference them by seq. Idempotent.
111    pub fn enable_seg_rows(&mut self, dir: &Path) -> Result<(), String> {
112        if self.segrows.is_some() {
113            return Ok(());
114        }
115        let mut segs = Vec::new();
116        let mut seq = 0u32;
117        if dir.exists() {
118            let m = kevy_seg::Manifest::open(dir).map_err(|e| e.to_string())?;
119            for e in m.live().filter(|e| e.meta.starts_with(ROW_TAG)) {
120                let Some(q) = seq_of(&e.file) else {
121                    return Err(format!("row segment '{}' has no parsable seq", e.file));
122                };
123                let seg = kevy_seg::Seg::open(&dir.join(&e.file))
124                    .map_err(|err| format!("open {}: {err}", e.file))?;
125                seq = seq.max(q + 1);
126                segs.push((
127                    q,
128                    SegSlot { seg: Arc::new(seg), file: e.file.clone(), live: 0, dead: 0 },
129                ));
130            }
131        }
132        // The gate opens only when cold values can actually exist:
133        // enabling the DIRECTORY costs nothing on the funnels; loaded
134        // segments (or the first sealed one, or a loaded stub) do.
135        self.cold_backing |= !segs.is_empty();
136        self.segrows = Some(SegRows { dir: dir.to_path_buf(), segs, seq });
137        Ok(())
138    }
139
140    /// After replay: rebuild each loaded segment's live count from the
141    /// stubs that actually reference it, and sweep the segments nothing
142    /// references — a crash between sealing and the SEGMENTED frame
143    /// leaves exactly such an orphan (its rows replayed hot).
144    pub fn sweep_orphan_row_segs(&mut self) {
145        let Some(sr) = &mut self.segrows else { return };
146        for (_, slot) in &mut sr.segs {
147            slot.live = 0;
148        }
149        for (_, e) in &self.map {
150            if let Value::Cold(c) = &e.value
151                && c.is_seg()
152                && let Some(slot) = sr.slot_mut(c.seg_ix())
153            {
154                slot.live += 1;
155            }
156        }
157        let mut m = match kevy_seg::Manifest::open(&sr.dir) {
158            Ok(m) => m,
159            Err(_) => return,
160        };
161        sr.segs.retain(|(_, slot)| {
162            if slot.live > 0 {
163                return true;
164            }
165            let _ = m.drop_seg(&slot.file);
166            let _ = std::fs::remove_file(sr.dir.join(&slot.file));
167            false
168        });
169        // Files the ledger never learned about (a crash mid-build)
170        // are plain garbage — the manifest sweep reclaims them.
171        let _ = m.sweep(&sr.dir);
172    }
173
174    /// The two-phase producer face: seal the batch (durable half) and
175    /// return `(seq, file)` for the caller to log a SEGMENTED frame
176    /// BEFORE [`Store::commit_row_eviction`] phase-changes the rows —
177    /// the R2c ordering (frame after the durable copy, before the hot
178    /// deletion) that makes every crash gap recoverable.
179    pub fn seal_rows_to_seg(
180        &mut self,
181        table: &[u8],
182        keys: &[Vec<u8>],
183    ) -> Result<Option<SealedRows>, String> {
184        if self.segrows.is_none() {
185            return Ok(None);
186        }
187        let mut rows: Vec<(&[u8], Vec<u8>)> = Vec::with_capacity(keys.len());
188        for key in keys {
189            if let Some(payload) = self.encode_evictable_row(key) {
190                rows.push((key.as_slice(), payload));
191            }
192        }
193        if rows.is_empty() {
194            return Ok(None);
195        }
196        rows.sort_by(|a, b| a.0.cmp(b.0));
197        let seq = self.build_row_segment(table, &rows)?;
198        let file = self.segrows.as_ref().expect("enabled above").slot(seq).file.clone();
199        // Only the keys actually sealed may phase-change — a filtered
200        // row (TTL-bearing, revived mid-batch, non-hash) stubbed at a
201        // segment that does not hold it would be a ghost.
202        let sealed = rows.iter().map(|(k, _)| k.to_vec()).collect();
203        Ok(Some(SealedRows { seq, file, keys: sealed }))
204    }
205
206    /// Phase-change the sealed batch after its SEGMENTED frame is
207    /// logged.
208    pub fn commit_row_eviction(&mut self, sealed: &SealedRows) -> u64 {
209        let mut n = 0u64;
210        for key in &sealed.keys {
211            if self.demote_row_to_seg(key, sealed.seq) {
212                n += 1;
213            }
214        }
215        if let Some(sr) = self.segrows.as_mut()
216            && let Some(slot) = sr.slot_mut(sealed.seq)
217        {
218            slot.live += n;
219        }
220        n
221    }
222
223    /// The row's encoded payload iff it is evictable right now.
224    fn encode_evictable_row(&mut self, key: &[u8]) -> Option<Vec<u8>> {
225        if self.hfttl.get(key).is_some_and(|m| !m.is_empty()) {
226            return None;
227        }
228        let e = self.live_entry(key)?;
229        if e.expire_at_ns.is_some() {
230            return None;
231        }
232        match &e.value {
233            Value::Hash(_) | Value::SmallHashInline(_) | Value::PackedRow(_) => {
234                tier_codec::encode(&e.value).map(|(payload, _tag)| payload)
235            }
236            _ => None,
237        }
238    }
239
240    /// Seal `rows` (key-ascending) into a manifest-registered segment
241    /// file and open it into the directory. The map is untouched.
242    fn build_row_segment(
243        &mut self,
244        table: &[u8],
245        rows: &[(&[u8], Vec<u8>)],
246    ) -> Result<u32, String> {
247        let sr = self.segrows.as_mut().expect("checked by caller");
248        std::fs::create_dir_all(&sr.dir).map_err(|e| e.to_string())?;
249        let seq = sr.seq;
250        let file = format!("row-{}-{}.seg", hex_stem(table), seq);
251        sr.seq += 1;
252        let path = sr.dir.join(&file);
253        let build = || -> Result<kevy_seg::SegMeta, String> {
254            let mut b = kevy_seg::SegBuilder::create(&path).map_err(|e| e.to_string())?;
255            for (k, payload) in rows {
256                b.push(k, payload).map_err(|e| e.to_string())?;
257            }
258            b.finish().map_err(|e| e.to_string())
259        };
260        let meta = build().inspect_err(|_| {
261            let _ = std::fs::remove_file(&path);
262        })?;
263        let mut m = kevy_seg::Manifest::open(&sr.dir).map_err(|e| e.to_string())?;
264        m.add(kevy_seg::ManifestEntry {
265            file: file.clone(),
266            meta: [ROW_TAG, table].concat(),
267            min_key: meta.min_key,
268            max_key: meta.max_key,
269            records: meta.records,
270        })
271        .map_err(|e| e.to_string())?;
272        let seg = kevy_seg::Seg::open(&path).map_err(|e| format!("reopen {file}: {e}"))?;
273        sr.segs.push((seq, SegSlot { seg: Arc::new(seg), file, live: 0, dead: 0 }));
274        self.cold_backing = true;
275        Ok(seq)
276    }
277
278    /// Phase-change one row to a seg-backed stub: the demote_in_place
279    /// twin without the vlog append (the value is already sealed in
280    /// the segment). Preserves TTL/LRU (both None/irrelevant here by
281    /// the eviction filter), fires no events, clears no field TTLs.
282    pub(crate) fn demote_row_to_seg(&mut self, key: &[u8], seg_ix: u32) -> bool {
283        let Some(e) = self.map.get_mut(key) else { return false };
284        if !matches!(e.value, Value::Hash(_) | Value::SmallHashInline(_) | Value::PackedRow(_)) {
285            return false;
286        }
287        let key_heap = key_heap_bytes_for(key);
288        let old_w = e.weight();
289        let value_w = old_w.saturating_sub(key_heap);
290        let stub = ColdRef::seg(seg_ix, value_w.min(u64::from(u32::MAX)) as u32, COLD_TAG_HASH);
291        let old_value = core::mem::replace(&mut e.value, Value::Cold(stub));
292        e.set_weight(key_heap);
293        crate::apply_delta(&mut self.used_memory, -(value_w as i64));
294        self.maybe_offload_drop(old_value);
295        true
296    }
297
298    /// The loaded seq for a manifest-registered row-segment file name.
299    pub(crate) fn row_seg_seq(&self, file: &str) -> Option<u32> {
300        let sr = self.segrows.as_ref()?;
301        sr.segs.iter().find(|(_, s)| s.file == file).map(|(q, _)| *q)
302    }
303
304    /// Every `(key, payload)` in segment `seq` — the replay stitch's
305    /// walk. Collected owned: the caller mutates the map while
306    /// stitching.
307    pub(crate) fn row_seg_records(&self, seq: u32) -> Vec<(Vec<u8>, Vec<u8>)> {
308        let sr = self.segrows.as_ref().expect("stitch ⇒ enabled");
309        let slot = sr.slot(seq);
310        let (lo, hi) = (slot.seg.meta().min_key.clone(), slot.seg.meta().max_key.clone());
311        slot.seg
312            .range(&lo, &hi)
313            .map(|r| r.expect("segrows: segment read failed — refused, not healed"))
314            .collect()
315    }
316
317    /// Insert a stub entry for a row the log never rebuilt hot (a
318    /// rewritten log carries no cold-row commands). No events, no TTL
319    /// (cold rows are TTL-free by the eviction filter).
320    pub(crate) fn insert_row_stub(&mut self, key: &[u8], seq: u32, value_weight: u64) {
321        let stub = ColdRef::seg(seq, value_weight.min(u64::from(u32::MAX)) as u32, COLD_TAG_HASH);
322        let key_heap = key_heap_bytes_for(key);
323        let mut e = crate::Entry::new(Value::Cold(stub), None);
324        e.set_weight(key_heap);
325        crate::apply_delta(&mut self.used_memory, key_heap as i64);
326        self.map.insert(crate::SmallBytes::from_slice(key), e);
327    }
328
329    /// Load one snapshot stub record: the row's identity re-enters the
330    /// map as a seg-backed stub (the segment directory, loaded before
331    /// the snapshot, holds its data). TTL-free by the eviction filter.
332    pub fn load_row_stub(&mut self, key: Vec<u8>, seq: u32, value_weight: u32) {
333        self.cold_backing = true;
334        self.insert_row_stub(&key, seq, u64::from(value_weight));
335    }
336
337    /// Fold a replay stitch's count into the segment's live tally.
338    pub(crate) fn note_stitched(&mut self, seq: u32, n: u64) {
339        if let Some(sr) = self.segrows.as_mut()
340            && let Some(slot) = sr.slot_mut(seq)
341        {
342            slot.live += n;
343        }
344    }
345
346    /// Decode a seg-backed stub's row. The panic doctrine matches the
347    /// vlog's: a stub pointing at a missing/corrupt record is a
348    /// process bug, surfaced loudly.
349    pub(crate) fn segrow_read(&self, cref: ColdRef, key: &[u8]) -> Value {
350        self.segrows.as_ref().expect("seg-backed stub ⇒ segrows enabled").read(cref, key)
351    }
352
353    /// A seg-backed stub died (DEL / expiry / promote / FLUSH): the
354    /// segment record is now stranded — count it for compaction.
355    pub(crate) fn segrow_note_dead(&mut self, cref: ColdRef) {
356        if let Some(sr) = &mut self.segrows
357            && let Some(slot) = sr.slot_mut(cref.seg_ix())
358        {
359            slot.dead += 1;
360            slot.live = slot.live.saturating_sub(1);
361        }
362    }
363
364    /// The live row segments' `(seq, file)` identities — the rewrite's
365    /// trailing SEGMENTED frames name these.
366    pub fn row_seg_files(&self) -> Vec<(u32, String)> {
367        self.segrows
368            .as_ref()
369            .map(|sr| sr.segs.iter().map(|(q, s)| (*q, s.file.clone())).collect())
370            .unwrap_or_default()
371    }
372
373    /// The open segment handles, for a snapshot view's pins.
374    pub(crate) fn segrow_pins(&self) -> Vec<(u32, Arc<kevy_seg::Seg>)> {
375        self.segrows
376            .as_ref()
377            .map(|sr| sr.segs.iter().map(|(q, s)| (*q, s.seg.clone())).collect())
378            .unwrap_or_default()
379    }
380
381    /// FLUSHALL/FLUSHDB: every stub died with the map; the segments
382    /// are all garbage. Unregister and unlink now (the ledger never
383    /// points at nothing, and nothing points at the ledger).
384    pub(crate) fn segrows_flush(&mut self) {
385        let Some(sr) = &mut self.segrows else { return };
386        if let Ok(mut m) = kevy_seg::Manifest::open(&sr.dir) {
387            let stale: Vec<String> =
388                m.live().filter(|e| e.meta.starts_with(ROW_TAG)).map(|e| e.file.clone()).collect();
389            for f in stale {
390                let _ = m.drop_seg(&f);
391                let _ = std::fs::remove_file(sr.dir.join(&f));
392            }
393        }
394        sr.segs.clear();
395    }
396}
397
398/// Table names are free bytes; the file name needs a safe stem.
399fn hex_stem(name: &[u8]) -> String {
400    name.iter().map(|b| format!("{b:02x}")).collect()
401}
402
403/// The stable seq embedded in a row-segment file name
404/// (`row-<hex>-<seq>.seg`).
405fn seq_of(file: &str) -> Option<u32> {
406    file.strip_suffix(".seg")?.rsplit('-').next()?.parse().ok()
407}