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}