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}