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}