#![expect(clippy::let_underscore_must_use, reason = "removing what is already meant to be gone")]
#![warn(missing_docs)]
use std::collections::HashMap;
use std::path::Path;
#[path = "text.rs"]
mod text;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
pub use text::{ColdHit, ColdPage, ColdPageQuery, TextColdDir};
use kevy_index::{
ColdBloom, ColdEntryRow, FacetBucket, IndexValue, ScalarClauses, ScalarHit, ValType,
WindowAudit, WindowShape, WindowSpec, claused_over, decode_seg_key, decode_seg_values,
encode_seg_values, seg_bounds, seg_key, values_pass, window_bound, window_value_of,
};
#[derive(Debug)]
pub struct WindowRt {
pub spec: WindowSpec,
pub shape: WindowShape,
w: i64,
seq: u64,
cold: Vec<(u64, kevy_seg::Seg)>,
bloom: ColdBloom,
tombs: HashMap<Vec<u8>, u64>,
pub idle_ticks: u64,
cleaned: bool,
}
impl WindowRt {
pub fn new(spec: WindowSpec, shape: WindowShape) -> Self {
Self {
spec,
shape,
w: i64::MIN,
seq: 0,
cold: Vec::new(),
bloom: ColdBloom::new(4096),
tombs: HashMap::new(),
idle_ticks: 0,
cleaned: false,
}
}
pub fn has_cold(&self) -> bool {
!self.cold.is_empty()
}
pub fn boundary(&self) -> i64 {
self.w
}
fn shadowed(&self, row: &[u8], seq: u64) -> bool {
self.tombs.get(row).is_some_and(|&reach| seq < reach)
}
pub fn on_row_write(&mut self, row_key: &[u8]) {
if self.bloom.contains(row_key) {
self.tombs.insert(row_key.to_vec(), self.seq);
}
}
pub fn audit(&self, ty: ValType) -> Option<WindowAudit> {
if self.w == i64::MIN {
return None;
}
let mut cold_live = 0u64;
for (seq, seg) in &self.cold {
let (lo, hi) = (seg.meta().min_key.clone(), seg.meta().max_key.clone());
if self.tombs.is_empty() {
cold_live += seg.count_range(&lo, &hi).ok()?;
continue;
}
for r in seg.range(&lo, &hi) {
let (k, _) = r.ok()?;
let Some((_, row)) = decode_seg_key(ty, &k) else { continue };
if !self.shadowed(&row, *seq) {
cold_live += 1;
}
}
}
Some(WindowAudit { boundary: self.w, shape: self.shape, cold_live })
}
pub fn cold_count(
&self,
ty: ValType,
min: &IndexValue,
max: &IndexValue,
) -> Result<u64, String> {
let (lo, hi) = seg_bounds(min, max);
if self.tombs.is_empty() {
let mut n = 0u64;
for (_, s) in &self.cold {
n += s.count_range(&lo, &hi).map_err(|e| e.to_string())?;
}
return Ok(n);
}
Ok(self.cold_hits(ty, min, max, None, usize::MAX)?.len() as u64)
}
pub fn cold_hits(
&self,
ty: ValType,
min: &IndexValue,
max: &IndexValue,
cursor: Option<&kevy_index::Cursor>,
limit: usize,
) -> Result<Vec<(Vec<u8>, IndexValue)>, String> {
let (lo, hi) = seg_bounds(min, max);
let mut out = Vec::new();
for (seq, seg) in &self.cold {
for r in seg.range(&lo, &hi) {
let (k, _) = r.map_err(|e| e.to_string())?;
let Some((v, row)) = decode_seg_key(ty, &k) else { continue };
if self.shadowed(&row, *seq) {
continue;
}
if cursor.is_some_and(|c| (&v, row.as_slice()) <= (&c.value, c.key.as_slice())) {
continue;
}
out.push((row, v));
if out.len() >= limit {
return Ok(out);
}
}
}
Ok(out)
}
pub fn cold_claused_count(
&self,
ty: ValType,
min: &IndexValue,
max: &IndexValue,
filters: &[(usize, kevy_index::ValueTest)],
) -> Result<u64, String> {
let mut n = 0u64;
for (_, _, vals) in self.decode_range(ty, min, max, None)? {
if values_pass(&vals, filters) {
n += 1;
}
}
Ok(n)
}
pub fn cold_claused(
&self,
ty: ValType,
min: &IndexValue,
max: &IndexValue,
cursor: Option<&kevy_index::Cursor>,
c: &ScalarClauses<'_>,
) -> Result<(Vec<ScalarHit>, Vec<Vec<FacetBucket>>), String> {
let items = self.decode_range(ty, min, max, cursor)?;
Ok(claused_over(items.into_iter(), c))
}
fn decode_range(
&self,
ty: ValType,
min: &IndexValue,
max: &IndexValue,
cursor: Option<&kevy_index::Cursor>,
) -> Result<Vec<ColdEntryRow>, String> {
let (lo, hi) = seg_bounds(min, max);
let mut out = Vec::new();
for (seq, seg) in &self.cold {
for r in seg.range(&lo, &hi) {
let (k, payload) = r.map_err(|e| e.to_string())?;
let (v, row) =
decode_seg_key(ty, &k).ok_or_else(|| "corrupt cold key".to_string())?;
if self.shadowed(&row, *seq) {
continue;
}
if cursor.is_some_and(|c| (&v, row.as_slice()) <= (&c.value, c.key.as_slice())) {
continue;
}
let vals = decode_seg_values(&payload)
.ok_or_else(|| "corrupt cold payload".to_string())?;
out.push((v, row, vals));
}
}
Ok(out)
}
pub fn pending_rows(&self, seg: &kevy_index::Segment) -> Option<Vec<Vec<u8>>> {
let max = window_value_of(seg.max_value()?, self.shape)?;
let target = bucket_floor(max.saturating_sub(self.spec.span), self.spec.bucket);
if target <= self.w {
return None;
}
let bound = window_bound(target, self.shape);
let rows: Vec<Vec<u8>> = seg.iter_below(&bound).map(|(_, k)| k.to_vec()).collect();
(!rows.is_empty()).then_some(rows)
}
pub fn slide(
&mut self,
index_name: &[u8],
seg: &mut kevy_index::Segment,
segs_dir: &Path,
) -> Result<bool, String> {
let Some(max) = seg.max_value().and_then(|v| window_value_of(v, self.shape)) else {
self.idle_ticks += 1;
return Ok(false);
};
let target = bucket_floor(max.saturating_sub(self.spec.span), self.spec.bucket);
if target <= self.w {
self.idle_ticks += 1;
return Ok(false);
}
let bound = window_bound(target, self.shape);
if seg.iter_below(&bound).next().is_none() {
self.w = target;
return Ok(false);
}
if !self.cleaned {
clean_stale_derived(index_name, segs_dir)?;
self.cleaned = true;
}
let file = self.build_segment(index_name, seg, &bound, segs_dir)?;
let batch = seg.split_off_below(&bound);
for (_, k) in &batch {
self.bloom.insert(k);
}
self.cold.push((
self.seq - 1,
kevy_seg::Seg::open(&segs_dir.join(&file))
.map_err(|e| format!("reopen {file}: {e}"))?,
));
self.probe(index_name, batch.len());
self.w = target;
Ok(true)
}
fn probe(&self, index_name: &[u8], split_off: usize) {
if std::env::var_os("KEVY_PROBE_SLIDE").is_none() {
return;
}
let sealed = self.cold.last().map(|c| c.1.meta().records).unwrap_or(0);
eprintln!(
"PROBE slide {} sealed={sealed} split_off={split_off} tombs={} {}",
String::from_utf8_lossy(index_name),
self.tombs.len(),
if sealed as usize == split_off { "ok" } else { "MISMATCH" }
);
}
fn build_segment(
&mut self,
index_name: &[u8],
seg: &kevy_index::Segment,
bound: &IndexValue,
segs_dir: &Path,
) -> Result<String, String> {
std::fs::create_dir_all(segs_dir).map_err(|e| e.to_string())?;
let file = format!("idx-{}-{}.seg", hex_stem(index_name), self.seq);
self.seq += 1;
let path = segs_dir.join(&file);
let build = || -> Result<kevy_seg::SegMeta, String> {
let mut b = kevy_seg::SegBuilder::create(&path).map_err(|e| e.to_string())?;
for (v, k) in seg.iter_below(bound) {
let vals = seg.stored_row(k);
b.push(&seg_key(v, k), &encode_seg_values(&vals)).map_err(|e| e.to_string())?;
}
b.finish().map_err(|e| e.to_string())
};
let meta = build().inspect_err(|_| {
let _ = std::fs::remove_file(&path);
})?;
let mut m = kevy_seg::Manifest::open(segs_dir).map_err(|e| e.to_string())?;
m.add(kevy_seg::ManifestEntry {
file: file.clone(),
meta: [b"idxcold:", index_name].concat(),
min_key: meta.min_key,
max_key: meta.max_key,
records: meta.records,
})
.map_err(|e| e.to_string())?;
Ok(file)
}
}
fn clean_stale_derived(index_name: &[u8], segs_dir: &Path) -> Result<(), String> {
if !segs_dir.exists() {
return Ok(());
}
let mut m = kevy_seg::Manifest::open(segs_dir).map_err(|e| e.to_string())?;
let tag = [b"idxcold:", index_name].concat();
let stale: Vec<String> = m.live().filter(|e| e.meta == tag).map(|e| e.file.clone()).collect();
for f in stale {
m.drop_seg(&f).map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(segs_dir.join(&f));
}
Ok(())
}
fn bucket_floor(v: i64, bucket: i64) -> i64 {
v - v.rem_euclid(bucket)
}
fn hex_stem(name: &[u8]) -> String {
name.iter().map(|b| format!("{b:02x}")).collect()
}