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