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