Skip to main content

kevy_window/
lib.rs

1//! The sliding-window runtime for scalar indexes — shared by the
2//! server and the embedded store (one implementation, so the two
3//! faces cannot drift): boundary maintenance, the eviction slide,
4//! and the cold half of range/count.
5//!
6//! Cold segments are derived spill, not truth (the rows stay hot; the
7//! index is rebuilt from them on boot) — so a failed slide simply
8//! leaves the tree untouched (the batch is read before it is cut),
9//! and a restart drops the segment set and re-slides.
10
11//! Every public item here is documented and the lint holds it.
12#![warn(missing_docs)]
13use std::collections::HashMap;
14use std::path::Path;
15
16#[path = "text.rs"]
17mod text;
18
19#[cfg(test)]
20#[path = "tests.rs"]
21mod tests;
22pub use text::{ColdHit, ColdPage, ColdPageQuery, TextColdDir};
23
24use kevy_index::{
25    ColdBloom, ColdEntryRow, FacetBucket, IndexValue, ScalarClauses, ScalarHit, ValType,
26    WindowAudit, WindowShape, WindowSpec, claused_over, decode_seg_key, decode_seg_values,
27    encode_seg_values, seg_bounds, seg_key, values_pass, window_bound, window_value_of,
28};
29
30/// One index's window state on one shard.
31pub struct WindowRt {
32    /// The declared window — width, column and retention — as the catalog
33    /// recorded it. Fixed for the life of the index; everything else here
34    /// is state that moves under it.
35    pub spec: WindowSpec,
36    /// Which tree shape the boundary lives in — a plain i64 index or
37    /// a composite the window column leads (see [`WindowShape`]).
38    pub shape: WindowShape,
39    /// Current boundary (bucket-aligned): entries with value < w are
40    /// cold. `i64::MIN` = nothing evicted yet.
41    w: i64,
42    /// Segment file name counter.
43    seq: u64,
44    /// Sealed segments with the sequence number each was built under —
45    /// the number a tombstone is compared against.
46    cold: Vec<(u64, kevy_seg::Seg)>,
47    /// Rows that MAY have cold entries — consulted before spending a
48    /// tombstone on a write.
49    bloom: ColdBloom,
50    /// Rows whose cold entries are shadowed, each recorded with the
51    /// sequence number the shadow reaches: entries in segments sealed
52    /// BEFORE it are hidden, entries sealed after it are not.
53    ///
54    /// A flat set was wrong and lost rows for it. The set is fed by a
55    /// bloom, so a write can tombstone a row that has no cold entry at
56    /// all; when that row later slid, the stale shadow hid the live
57    /// entry it had just been given, permanently. Recording how far
58    /// the shadow reaches costs one `u64` and makes it exact — the
59    /// same property `text.rs` states for its own tombstones.
60    ///
61    /// A row earns one by being rewritten, deleted, or revived after
62    /// eviction. Memory-only: replayed writes re-earn them through the
63    /// same bloom on the rebuilt state.
64    tombs: HashMap<Vec<u8>, u64>,
65    /// Ticks that cost exactly one comparison (the idle-convergence
66    /// gate counter).
67    pub idle_ticks: u64,
68    /// Whether this boot's stale derived segments (a previous run's
69    /// spill for this index) were dropped yet. Done lazily on the
70    /// first slide: they are unreachable (the boundary restarts at
71    /// MIN) and their manifest entries would collide with this run's
72    /// file names.
73    cleaned: bool,
74}
75
76impl WindowRt {
77    /// An empty window state: boundary at `i64::MIN` so the first row
78    /// admitted sets it, no cold segments, and a fresh bloom. Nothing is
79    /// read from disk here — a restart rebuilds by replaying, not by
80    /// trusting a persisted boundary.
81    pub fn new(spec: WindowSpec, shape: WindowShape) -> Self {
82        Self {
83            spec,
84            shape,
85            w: i64::MIN,
86            seq: 0,
87            cold: Vec::new(),
88            bloom: ColdBloom::new(4096),
89            tombs: HashMap::new(),
90            idle_ticks: 0,
91            cleaned: false,
92        }
93    }
94
95    /// Whether any rows have been frozen out of the live tree. A query
96    /// that answers `false` here can skip the cold merge entirely, which
97    /// is the common case and the reason this is a field check rather
98    /// than a directory scan.
99    pub fn has_cold(&self) -> bool {
100        !self.cold.is_empty()
101    }
102
103    /// The current eviction boundary: entries with window value below
104    /// this are cold. `i64::MIN` = nothing has evicted yet. Read by
105    /// the window-narrowing observation (a query's `lower - boundary`
106    /// margin), never interpreted beyond ordering.
107    pub fn boundary(&self) -> i64 {
108        self.w
109    }
110
111    /// Is this row's entry in the segment sealed as `seq` shadowed?
112    /// A shadow reaches only backwards: it was recorded to hide what
113    /// existed when the row changed, and cannot hide what the row was
114    /// given afterwards.
115    fn shadowed(&self, row: &[u8], seq: u64) -> bool {
116        self.tombs.get(row).is_some_and(|&reach| seq < reach)
117    }
118
119    /// The write path saw `row_key` change: shadow whatever cold entry
120    /// it may have RIGHT NOW. A bloom false positive spends one stray
121    /// map entry that shadows nothing, which is the point: the reach
122    /// is the current sequence, and anything this row is given later
123    /// is sealed above it.
124    pub fn on_row_write(&mut self, row_key: &[u8]) {
125        if self.bloom.contains(row_key) {
126            self.tombs.insert(row_key.to_vec(), self.seq);
127        }
128    }
129
130    /// What an audit needs from the cold side: the boundary, the tree
131    /// shape, and how many entries are actually down there. `None`
132    /// until something has slid.
133    ///
134    /// The count is over each segment's OWN extent rather than a value
135    /// range, because the caller wants "everything cold" and building
136    /// an unbounded upper bound differs per tree shape — a segment
137    /// already knows its own first and last key.
138    pub fn audit(&self, ty: ValType) -> Option<WindowAudit> {
139        if self.w == i64::MIN {
140            return None;
141        }
142        let mut cold_live = 0u64;
143        for (seq, seg) in &self.cold {
144            let (lo, hi) = (seg.meta().min_key.clone(), seg.meta().max_key.clone());
145            if self.tombs.is_empty() {
146                cold_live += seg.count_range(&lo, &hi).ok()?;
147                continue;
148            }
149            // Tombstones are bloom-gated, so a stray one can name a row
150            // with no cold entry at all. Counting records minus tombs
151            // would under-report and the audit would invent a hole, so
152            // the live entries are counted directly.
153            for r in seg.range(&lo, &hi) {
154                let (k, _) = r.ok()?;
155                let Some((_, row)) = decode_seg_key(ty, &k) else { continue };
156                if !self.shadowed(&row, *seq) {
157                    cold_live += 1;
158                }
159            }
160        }
161        Some(WindowAudit { boundary: self.w, shape: self.shape, cold_live })
162    }
163
164    /// Cold count of values in `[min, max]`: fast whole-segment
165    /// arithmetic while no tombstones exist (the common state), a
166    /// decode walk once any do. `Err` = a segment refused (corrupt
167    /// derived spill) — the query reports it, never a partial number.
168    pub fn cold_count(
169        &self,
170        ty: ValType,
171        min: &IndexValue,
172        max: &IndexValue,
173    ) -> Result<u64, String> {
174        let (lo, hi) = seg_bounds(min, max);
175        if self.tombs.is_empty() {
176            let mut n = 0u64;
177            for (_, s) in &self.cold {
178                n += s.count_range(&lo, &hi).map_err(|e| e.to_string())?;
179            }
180            return Ok(n);
181        }
182        Ok(self.cold_hits(ty, min, max, None, usize::MAX)?.len() as u64)
183    }
184
185    /// Cold hits of `[min, max]` in value order, tombstones skipped
186    /// and — when a page resumes — everything at or before `cursor`
187    /// skipped BEFORE the limit counts, at most `limit`. (Counting
188    /// first and filtering at the merge starves the cold side on any
189    /// page after the first: the limit fills with pre-cursor entries
190    /// that are then all dropped.) Segments hold disjoint ascending
191    /// value ranges (each slide covers `[old_w, new_w)`), so chaining
192    /// them in creation order IS value order. `Err` on a corrupt
193    /// segment — never a silent partial page.
194    pub fn cold_hits(
195        &self,
196        ty: ValType,
197        min: &IndexValue,
198        max: &IndexValue,
199        cursor: Option<&kevy_index::Cursor>,
200        limit: usize,
201    ) -> Result<Vec<(Vec<u8>, IndexValue)>, String> {
202        let (lo, hi) = seg_bounds(min, max);
203        let mut out = Vec::new();
204        for (seq, seg) in &self.cold {
205            for r in seg.range(&lo, &hi) {
206                let (k, _) = r.map_err(|e| e.to_string())?;
207                let Some((v, row)) = decode_seg_key(ty, &k) else { continue };
208                if self.shadowed(&row, *seq) {
209                    continue;
210                }
211                if cursor.is_some_and(|c| (&v, row.as_slice()) <= (&c.value, c.key.as_slice())) {
212                    continue;
213                }
214                out.push((row, v));
215                if out.len() >= limit {
216                    return Ok(out);
217                }
218            }
219        }
220        Ok(out)
221    }
222
223    /// The clause-carrying cold count: the FILTER predicates applied
224    /// to each live cold entry's payload values. `Err` on a corrupt
225    /// segment — the query reports it, never a partial number.
226    pub fn cold_claused_count(
227        &self,
228        ty: ValType,
229        min: &IndexValue,
230        max: &IndexValue,
231        filters: &[(usize, kevy_index::ValueTest)],
232    ) -> Result<u64, String> {
233        let mut n = 0u64;
234        for (_, _, vals) in self.decode_range(ty, min, max, None)? {
235            if values_pass(&vals, filters) {
236                n += 1;
237            }
238        }
239        Ok(n)
240    }
241
242    /// The clause-carrying cold page: every live cold entry in
243    /// `[min, max]` (past `cursor` when one rides), decoded and fed to
244    /// the shared clause walk — the same FILTER / SORT / DISTINCT /
245    /// FACET semantics the hot tree runs, over the frozen payloads.
246    pub fn cold_claused(
247        &self,
248        ty: ValType,
249        min: &IndexValue,
250        max: &IndexValue,
251        cursor: Option<&kevy_index::Cursor>,
252        c: &ScalarClauses<'_>,
253    ) -> Result<(Vec<ScalarHit>, Vec<Vec<FacetBucket>>), String> {
254        let items = self.decode_range(ty, min, max, cursor)?;
255        Ok(claused_over(items.into_iter(), c))
256    }
257
258    /// Every live cold entry of `[min, max]` past `cursor`, decoded to
259    /// `(value, row_key, payload values)` in value order. `Err` on any
260    /// malformed key or payload — corrupt derived spill refuses.
261    fn decode_range(
262        &self,
263        ty: ValType,
264        min: &IndexValue,
265        max: &IndexValue,
266        cursor: Option<&kevy_index::Cursor>,
267    ) -> Result<Vec<ColdEntryRow>, String> {
268        let (lo, hi) = seg_bounds(min, max);
269        let mut out = Vec::new();
270        for (seq, seg) in &self.cold {
271            for r in seg.range(&lo, &hi) {
272                let (k, payload) = r.map_err(|e| e.to_string())?;
273                let (v, row) =
274                    decode_seg_key(ty, &k).ok_or_else(|| "corrupt cold key".to_string())?;
275                if self.shadowed(&row, *seq) {
276                    continue;
277                }
278                if cursor.is_some_and(|c| (&v, row.as_slice()) <= (&c.value, c.key.as_slice())) {
279                    continue;
280                }
281                let vals = decode_seg_values(&payload)
282                    .ok_or_else(|| "corrupt cold payload".to_string())?;
283                out.push((v, row, vals));
284            }
285        }
286        Ok(out)
287    }
288
289    /// The row keys that would evict if the boundary advanced now —
290    /// the row-eviction half reads this BEFORE [`Self::slide`] cuts
291    /// the index, so a failed row eviction leaves both layers hot and
292    /// the next tick retries the whole batch. No state changes.
293    pub fn pending_rows(&self, seg: &kevy_index::Segment) -> Option<Vec<Vec<u8>>> {
294        let max = window_value_of(seg.max_value()?, self.shape)?;
295        let target = bucket_floor(max.saturating_sub(self.spec.span), self.spec.bucket);
296        if target <= self.w {
297            return None;
298        }
299        let bound = window_bound(target, self.shape);
300        let rows: Vec<Vec<u8>> = seg.iter_below(&bound).map(|(_, k)| k.to_vec()).collect();
301        (!rows.is_empty()).then_some(rows)
302    }
303
304    /// Advance the boundary and evict the out-of-window tree prefix
305    /// into a segment. One comparison when there is nothing to do.
306    /// Build-then-cut: an I/O failure leaves the tree untouched and
307    /// the boundary unmoved — the next tick retries.
308    pub fn slide(
309        &mut self,
310        index_name: &[u8],
311        seg: &mut kevy_index::Segment,
312        segs_dir: &Path,
313    ) -> Result<bool, String> {
314        let Some(max) = seg.max_value().and_then(|v| window_value_of(v, self.shape)) else {
315            self.idle_ticks += 1;
316            return Ok(false);
317        };
318        let target = bucket_floor(max.saturating_sub(self.spec.span), self.spec.bucket);
319        if target <= self.w {
320            self.idle_ticks += 1;
321            return Ok(false);
322        }
323        let bound = window_bound(target, self.shape);
324        if seg.iter_below(&bound).next().is_none() {
325            self.w = target;
326            return Ok(false);
327        }
328        if !self.cleaned {
329            clean_stale_derived(index_name, segs_dir)?;
330            self.cleaned = true;
331        }
332        let file = self.build_segment(index_name, seg, &bound, segs_dir)?;
333        let batch = seg.split_off_below(&bound);
334        for (_, k) in &batch {
335            self.bloom.insert(k);
336        }
337        // `seq` was consumed by `build_segment`, so this file's own
338        // number is one below the counter it left behind.
339        self.cold.push((
340            self.seq - 1,
341            kevy_seg::Seg::open(&segs_dir.join(&file))
342                .map_err(|e| format!("reopen {file}: {e}"))?,
343        ));
344        self.probe(index_name, batch.len());
345        self.w = target;
346        Ok(true)
347    }
348
349    /// `KEVY_PROBE_SLIDE=1`: one line per slide with what was sealed,
350    /// what left the tree, and how many shadows are outstanding.
351    ///
352    /// This is the instrument that found the stale-tombstone loss. The
353    /// first three numbers refute the obvious theory (the seal drops
354    /// what arrives mid-build — it does not; sealed always equals
355    /// split_off), which is what left the tombstone count as the only
356    /// remaining place the missing rows could be.
357    fn probe(&self, index_name: &[u8], split_off: usize) {
358        if std::env::var_os("KEVY_PROBE_SLIDE").is_none() {
359            return;
360        }
361        let sealed = self.cold.last().map(|c| c.1.meta().records).unwrap_or(0);
362        eprintln!(
363            "PROBE slide {} sealed={sealed} split_off={split_off} tombs={} {}",
364            String::from_utf8_lossy(index_name),
365            self.tombs.len(),
366            if sealed as usize == split_off { "ok" } else { "MISMATCH" }
367        );
368    }
369
370    /// Seal the below-bound prefix into a manifest-registered segment
371    /// file; the tree is not touched.
372    fn build_segment(
373        &mut self,
374        index_name: &[u8],
375        seg: &kevy_index::Segment,
376        bound: &IndexValue,
377        segs_dir: &Path,
378    ) -> Result<String, String> {
379        std::fs::create_dir_all(segs_dir).map_err(|e| e.to_string())?;
380        let file = format!("idx-{}-{}.seg", hex_stem(index_name), self.seq);
381        self.seq += 1;
382        let path = segs_dir.join(&file);
383        let build = || -> Result<kevy_seg::SegMeta, String> {
384            let mut b = kevy_seg::SegBuilder::create(&path).map_err(|e| e.to_string())?;
385            for (v, k) in seg.iter_below(bound) {
386                // The payload carries the row's stored VALUES so the
387                // clause-carrying cold path never re-reads the row
388                // (which may itself have gone cold). No declared
389                // values = the empty payload, the a-train shape.
390                let vals = seg.stored_row(k);
391                b.push(&seg_key(v, k), &encode_seg_values(&vals)).map_err(|e| e.to_string())?;
392            }
393            b.finish().map_err(|e| e.to_string())
394        };
395        let meta = build().inspect_err(|_| {
396            let _ = std::fs::remove_file(&path);
397        })?;
398        let mut m = kevy_seg::Manifest::open(segs_dir).map_err(|e| e.to_string())?;
399        m.add(kevy_seg::ManifestEntry {
400            file: file.clone(),
401            meta: [b"idxcold:", index_name].concat(),
402            min_key: meta.min_key,
403            max_key: meta.max_key,
404            records: meta.records,
405        })
406        .map_err(|e| e.to_string())?;
407        Ok(file)
408    }
409}
410
411/// Drop a previous run's derived segments for `index_name`: their
412/// manifest entries unregister first, then the files unlink (the
413/// ledger never points at nothing).
414fn clean_stale_derived(index_name: &[u8], segs_dir: &Path) -> Result<(), String> {
415    if !segs_dir.exists() {
416        return Ok(());
417    }
418    let mut m = kevy_seg::Manifest::open(segs_dir).map_err(|e| e.to_string())?;
419    let tag = [b"idxcold:", index_name].concat();
420    let stale: Vec<String> = m.live().filter(|e| e.meta == tag).map(|e| e.file.clone()).collect();
421    for f in stale {
422        m.drop_seg(&f).map_err(|e| e.to_string())?;
423        let _ = std::fs::remove_file(segs_dir.join(&f));
424    }
425    Ok(())
426}
427
428/// The window boundary advances in whole buckets (floor).
429fn bucket_floor(v: i64, bucket: i64) -> i64 {
430    v - v.rem_euclid(bucket)
431}
432
433/// Index names are free bytes; the segment file name needs a safe
434/// stem. Hex is unambiguous and the manifest carries the real name.
435fn hex_stem(name: &[u8]) -> String {
436    name.iter().map(|b| format!("{b:02x}")).collect()
437}