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((q, SegSlot { seg: Arc::new(seg), file: e.file.clone(), live: 0, dead: 0 }));
127            }
128        }
129        // The gate opens only when cold values can actually exist:
130        // enabling the DIRECTORY costs nothing on the funnels; loaded
131        // segments (or the first sealed one, or a loaded stub) do.
132        self.cold_backing |= !segs.is_empty();
133        self.segrows = Some(SegRows { dir: dir.to_path_buf(), segs, seq });
134        Ok(())
135    }
136
137    /// After replay: rebuild each loaded segment's live count from the
138    /// stubs that actually reference it, and sweep the segments nothing
139    /// references — a crash between sealing and the SEGMENTED frame
140    /// leaves exactly such an orphan (its rows replayed hot).
141    pub fn sweep_orphan_row_segs(&mut self) {
142        let Some(sr) = &mut self.segrows else { return };
143        for (_, slot) in &mut sr.segs {
144            slot.live = 0;
145        }
146        for (_, e) in &self.map {
147            if let Value::Cold(c) = &e.value
148                && c.is_seg()
149                && let Some(slot) = sr.slot_mut(c.seg_ix())
150            {
151                slot.live += 1;
152            }
153        }
154        let mut m = match kevy_seg::Manifest::open(&sr.dir) {
155            Ok(m) => m,
156            Err(_) => return,
157        };
158        sr.segs.retain(|(_, slot)| {
159            if slot.live > 0 {
160                return true;
161            }
162            let _ = m.drop_seg(&slot.file);
163            let _ = std::fs::remove_file(sr.dir.join(&slot.file));
164            false
165        });
166        // Files the ledger never learned about (a crash mid-build)
167        // are plain garbage — the manifest sweep reclaims them.
168        let _ = m.sweep(&sr.dir);
169    }
170
171    /// The two-phase producer face: seal the batch (durable half) and
172    /// return `(seq, file)` for the caller to log a SEGMENTED frame
173    /// BEFORE [`Store::commit_row_eviction`] phase-changes the rows —
174    /// the R2c ordering (frame after the durable copy, before the hot
175    /// deletion) that makes every crash gap recoverable.
176    pub fn seal_rows_to_seg(
177        &mut self,
178        table: &[u8],
179        keys: &[Vec<u8>],
180    ) -> Result<Option<SealedRows>, String> {
181        if self.segrows.is_none() {
182            return Ok(None);
183        }
184        let mut rows: Vec<(&[u8], Vec<u8>)> = Vec::with_capacity(keys.len());
185        for key in keys {
186            if let Some(payload) = self.encode_evictable_row(key) {
187                rows.push((key.as_slice(), payload));
188            }
189        }
190        if rows.is_empty() {
191            return Ok(None);
192        }
193        rows.sort_by(|a, b| a.0.cmp(b.0));
194        let seq = self.build_row_segment(table, &rows)?;
195        let file = self
196            .segrows
197            .as_ref()
198            .expect("enabled above")
199            .slot(seq)
200            .file
201            .clone();
202        // Only the keys actually sealed may phase-change — a filtered
203        // row (TTL-bearing, revived mid-batch, non-hash) stubbed at a
204        // segment that does not hold it would be a ghost.
205        let sealed = rows.iter().map(|(k, _)| k.to_vec()).collect();
206        Ok(Some(SealedRows { seq, file, keys: sealed }))
207    }
208
209    /// Phase-change the sealed batch after its SEGMENTED frame is
210    /// logged.
211    pub fn commit_row_eviction(&mut self, sealed: &SealedRows) -> u64 {
212        let mut n = 0u64;
213        for key in &sealed.keys {
214            if self.demote_row_to_seg(key, sealed.seq) {
215                n += 1;
216            }
217        }
218        if let Some(sr) = self.segrows.as_mut()
219            && let Some(slot) = sr.slot_mut(sealed.seq)
220        {
221            slot.live += n;
222        }
223        n
224    }
225
226    /// The row's encoded payload iff it is evictable right now.
227    fn encode_evictable_row(&mut self, key: &[u8]) -> Option<Vec<u8>> {
228        if self.hfttl.get(key).is_some_and(|m| !m.is_empty()) {
229            return None;
230        }
231        let e = self.live_entry(key)?;
232        if e.expire_at_ns.is_some() {
233            return None;
234        }
235        match &e.value {
236            Value::Hash(_) | Value::SmallHashInline(_) => {
237                tier_codec::encode(&e.value).map(|(payload, _tag)| payload)
238            }
239            _ => None,
240        }
241    }
242
243    /// Seal `rows` (key-ascending) into a manifest-registered segment
244    /// file and open it into the directory. The map is untouched.
245    fn build_row_segment(&mut self, table: &[u8], rows: &[(&[u8], Vec<u8>)]) -> Result<u32, String> {
246        let sr = self.segrows.as_mut().expect("checked by caller");
247        std::fs::create_dir_all(&sr.dir).map_err(|e| e.to_string())?;
248        let seq = sr.seq;
249        let file = format!("row-{}-{}.seg", hex_stem(table), seq);
250        sr.seq += 1;
251        let path = sr.dir.join(&file);
252        let build = || -> Result<kevy_seg::SegMeta, String> {
253            let mut b = kevy_seg::SegBuilder::create(&path).map_err(|e| e.to_string())?;
254            for (k, payload) in rows {
255                b.push(k, payload).map_err(|e| e.to_string())?;
256            }
257            b.finish().map_err(|e| e.to_string())
258        };
259        let meta = build().inspect_err(|_| {
260            let _ = std::fs::remove_file(&path);
261        })?;
262        let mut m = kevy_seg::Manifest::open(&sr.dir).map_err(|e| e.to_string())?;
263        m.add(kevy_seg::ManifestEntry {
264            file: file.clone(),
265            meta: [ROW_TAG, table].concat(),
266            min_key: meta.min_key,
267            max_key: meta.max_key,
268            records: meta.records,
269        })
270        .map_err(|e| e.to_string())?;
271        let seg = kevy_seg::Seg::open(&path).map_err(|e| format!("reopen {file}: {e}"))?;
272        sr.segs.push((seq, SegSlot { seg: Arc::new(seg), file, live: 0, dead: 0 }));
273        self.cold_backing = true;
274        Ok(seq)
275    }
276
277    /// Phase-change one row to a seg-backed stub: the demote_in_place
278    /// twin without the vlog append (the value is already sealed in
279    /// the segment). Preserves TTL/LRU (both None/irrelevant here by
280    /// the eviction filter), fires no events, clears no field TTLs.
281    pub(crate) fn demote_row_to_seg(&mut self, key: &[u8], seg_ix: u32) -> bool {
282        let Some(e) = self.map.get_mut(key) else { return false };
283        if !matches!(e.value, Value::Hash(_) | Value::SmallHashInline(_)) {
284            return false;
285        }
286        let key_heap = key_heap_bytes_for(key);
287        let old_w = e.weight();
288        let value_w = old_w.saturating_sub(key_heap);
289        let stub = ColdRef::seg(seg_ix, value_w.min(u64::from(u32::MAX)) as u32, COLD_TAG_HASH);
290        let old_value = core::mem::replace(&mut e.value, Value::Cold(stub));
291        e.set_weight(key_heap);
292        crate::apply_delta(&mut self.used_memory, -(value_w as i64));
293        self.maybe_offload_drop(old_value);
294        true
295    }
296
297    /// The loaded seq for a manifest-registered row-segment file name.
298    pub(crate) fn row_seg_seq(&self, file: &str) -> Option<u32> {
299        let sr = self.segrows.as_ref()?;
300        sr.segs.iter().find(|(_, s)| s.file == file).map(|(q, _)| *q)
301    }
302
303    /// Every `(key, payload)` in segment `seq` — the replay stitch's
304    /// walk. Collected owned: the caller mutates the map while
305    /// stitching.
306    pub(crate) fn row_seg_records(&self, seq: u32) -> Vec<(Vec<u8>, Vec<u8>)> {
307        let sr = self.segrows.as_ref().expect("stitch ⇒ enabled");
308        let slot = sr.slot(seq);
309        let (lo, hi) = (slot.seg.meta().min_key.clone(), slot.seg.meta().max_key.clone());
310        slot.seg
311            .range(&lo, &hi)
312            .map(|r| r.expect("segrows: segment read failed — refused, not healed"))
313            .collect()
314    }
315
316    /// Insert a stub entry for a row the log never rebuilt hot (a
317    /// rewritten log carries no cold-row commands). No events, no TTL
318    /// (cold rows are TTL-free by the eviction filter).
319    pub(crate) fn insert_row_stub(&mut self, key: &[u8], seq: u32, value_weight: u64) {
320        let stub = ColdRef::seg(seq, value_weight.min(u64::from(u32::MAX)) as u32, COLD_TAG_HASH);
321        let key_heap = key_heap_bytes_for(key);
322        let mut e = crate::Entry::new(Value::Cold(stub), None);
323        e.set_weight(key_heap);
324        crate::apply_delta(&mut self.used_memory, key_heap as i64);
325        self.map.insert(crate::SmallBytes::from_slice(key), e);
326    }
327
328    /// Load one snapshot stub record: the row's identity re-enters the
329    /// map as a seg-backed stub (the segment directory, loaded before
330    /// the snapshot, holds its data). TTL-free by the eviction filter.
331    pub fn load_row_stub(&mut self, key: Vec<u8>, seq: u32, value_weight: u32) {
332        self.cold_backing = true;
333        self.insert_row_stub(&key, seq, u64::from(value_weight));
334    }
335
336    /// Fold a replay stitch's count into the segment's live tally.
337    pub(crate) fn note_stitched(&mut self, seq: u32, n: u64) {
338        if let Some(sr) = self.segrows.as_mut()
339            && let Some(slot) = sr.slot_mut(seq)
340        {
341            slot.live += n;
342        }
343    }
344
345    /// Decode a seg-backed stub's row. The panic doctrine matches the
346    /// vlog's: a stub pointing at a missing/corrupt record is a
347    /// process bug, surfaced loudly.
348    pub(crate) fn segrow_read(&self, cref: ColdRef, key: &[u8]) -> Value {
349        self.segrows
350            .as_ref()
351            .expect("seg-backed stub ⇒ segrows enabled")
352            .read(cref, key)
353    }
354
355    /// A seg-backed stub died (DEL / expiry / promote / FLUSH): the
356    /// segment record is now stranded — count it for compaction.
357    pub(crate) fn segrow_note_dead(&mut self, cref: ColdRef) {
358        if let Some(sr) = &mut self.segrows
359            && let Some(slot) = sr.slot_mut(cref.seg_ix())
360        {
361            slot.dead += 1;
362            slot.live = slot.live.saturating_sub(1);
363        }
364    }
365
366    /// The live row segments' `(seq, file)` identities — the rewrite's
367    /// trailing SEGMENTED frames name these.
368    pub fn row_seg_files(&self) -> Vec<(u32, String)> {
369        self.segrows
370            .as_ref()
371            .map(|sr| sr.segs.iter().map(|(q, s)| (*q, s.file.clone())).collect())
372            .unwrap_or_default()
373    }
374
375    /// The open segment handles, for a snapshot view's pins.
376    pub(crate) fn segrow_pins(&self) -> Vec<(u32, Arc<kevy_seg::Seg>)> {
377        self.segrows
378            .as_ref()
379            .map(|sr| sr.segs.iter().map(|(q, s)| (*q, s.seg.clone())).collect())
380            .unwrap_or_default()
381    }
382
383    /// FLUSHALL/FLUSHDB: every stub died with the map; the segments
384    /// are all garbage. Unregister and unlink now (the ledger never
385    /// points at nothing, and nothing points at the ledger).
386    pub(crate) fn segrows_flush(&mut self) {
387        let Some(sr) = &mut self.segrows else { return };
388        if let Ok(mut m) = kevy_seg::Manifest::open(&sr.dir) {
389            let stale: Vec<String> = m
390                .live()
391                .filter(|e| e.meta.starts_with(ROW_TAG))
392                .map(|e| e.file.clone())
393                .collect();
394            for f in stale {
395                let _ = m.drop_seg(&f);
396                let _ = std::fs::remove_file(sr.dir.join(&f));
397            }
398        }
399        sr.segs.clear();
400    }
401}
402
403/// Table names are free bytes; the file name needs a safe stem.
404fn hex_stem(name: &[u8]) -> String {
405    name.iter().map(|b| format!("{b:02x}")).collect()
406}
407
408/// The stable seq embedded in a row-segment file name
409/// (`row-<hex>-<seq>.seg`).
410fn seq_of(file: &str) -> Option<u32> {
411    file.strip_suffix(".seg")?.rsplit('-').next()?.parse().ok()
412}