kevy_index/segcold.rs
1//! Cold-segment support for scalar indexes: the order-preserving
2//! `(value, row_key)` byte-key codec an evicted tree entry is stored
3//! under, and the membership bloom that tells the write path a row MAY
4//! have a cold entry (so it earns a tombstone instead of a ghost).
5//!
6//! The key is `frame(order_bytes(value)) ‖ frame(row_key)` — the
7//! composite codec's escape-and-terminate framing (0x00 → 0x00 0xFF,
8//! terminator 0x00 0x00; the terminator sorts below every escaped
9//! continuation byte, so memcmp order equals tuple order), without the
10//! composite column-length cap: a scalar index puts no bound on str
11//! values or row keys, so neither does its cold key.
12
13use crate::catalog::ValType;
14use crate::value::IndexValue;
15
16pub(crate) fn value_order_bytes(v: &IndexValue) -> Vec<u8> {
17 match v {
18 IndexValue::Str(s) => s.clone(),
19 IndexValue::I64(i) => ((*i as u64) ^ (1 << 63)).to_be_bytes().to_vec(),
20 IndexValue::F64(f) => {
21 let b = f.to_bits();
22 let m = if b >> 63 == 1 { !b } else { b | (1 << 63) };
23 m.to_be_bytes().to_vec()
24 }
25 }
26}
27
28/// `(value, row_key)` → the strictly-orderable segment key.
29///
30/// ```
31/// use kevy_index::{IndexValue, seg_key};
32/// // memcmp order of the keys is (value, row_key) tuple order
33/// let a = seg_key(&IndexValue::I64(-5), b"zz");
34/// let b = seg_key(&IndexValue::I64(3), b"aa");
35/// let c = seg_key(&IndexValue::I64(3), b"ab");
36/// assert!(a < b && b < c);
37/// ```
38pub fn seg_key(v: &IndexValue, row_key: &[u8]) -> Vec<u8> {
39 let vb = v.order_bytes();
40 let mut out = Vec::with_capacity(vb.len() + row_key.len() + 4);
41 frame_into(&mut out, &vb);
42 frame_into(&mut out, row_key);
43 out
44}
45
46/// Decode a segment key back to `(value, row_key)`. `ty` is the
47/// index's declared type (the key does not carry it). `None` on any
48/// malformed frame — a corrupt key is a refusal upstream, never a
49/// guessed entry.
50///
51/// ```
52/// use kevy_index::{IndexValue, ValType, decode_seg_key, seg_key};
53/// let key = seg_key(&IndexValue::F64(-2.5), b"row\0:1");
54/// let (v, row) = decode_seg_key(ValType::F64, &key).expect("well-formed");
55/// assert_eq!((v, row), (IndexValue::F64(-2.5), b"row\0:1".to_vec()));
56/// assert_eq!(decode_seg_key(ValType::F64, &key[..key.len() - 1]), None);
57/// ```
58pub fn decode_seg_key(ty: ValType, key: &[u8]) -> Option<(IndexValue, Vec<u8>)> {
59 let (vb, rest) = unframe(key)?;
60 let (row, tail) = unframe(rest)?;
61 if !tail.is_empty() {
62 return None;
63 }
64 let value = match ty {
65 ValType::Str => IndexValue::Str(vb),
66 ValType::I64 => {
67 let raw = u64::from_be_bytes(vb.try_into().ok()?);
68 IndexValue::I64((raw ^ (1 << 63)) as i64)
69 }
70 ValType::F64 => {
71 let m = u64::from_be_bytes(vb.try_into().ok()?);
72 let b = if m >> 63 == 1 { m & !(1 << 63) } else { !m };
73 IndexValue::F64(f64::from_bits(b))
74 }
75 _ => return None,
76 };
77 Some((value, row))
78}
79
80/// Inclusive byte bounds covering exactly the cold keys whose value
81/// lies in `[min, max]`. The upper bound is the max value's frame with
82/// its final terminator byte flipped to 0x01: every `frame(max) ‖
83/// frame(row)` key sorts below it (the shared prefix ends 0x00 0x00 <
84/// 0x00 0x01), and no larger value's frame can reach it (in the
85/// escaped alphabet 0x00 is always followed by 0xFF, so the flipped
86/// terminator is nobody's prefix).
87///
88/// ```
89/// use kevy_index::{IndexValue, seg_bounds, seg_key};
90/// let (lo, hi) = seg_bounds(&IndexValue::I64(10), &IndexValue::I64(20));
91/// let within = |v: i64| {
92/// let k = seg_key(&IndexValue::I64(v), b"row");
93/// lo <= k && k <= hi
94/// };
95/// assert!(within(10) && within(15) && within(20));
96/// assert!(!within(9) && !within(21));
97/// ```
98pub fn seg_bounds(min: &IndexValue, max: &IndexValue) -> (Vec<u8>, Vec<u8>) {
99 let mut lo = Vec::new();
100 frame_into(&mut lo, &min.order_bytes());
101 let mut hi = Vec::new();
102 frame_into(&mut hi, &max.order_bytes());
103 *hi.last_mut().expect("frame is never empty") = 0x01;
104 (lo, hi)
105}
106
107/// Which shape of tree a window boundary lives in: a plain i64
108/// window-column index, or a composite index the window column LEADS
109/// (ascending — its first component is the column's `order_key` 8B,
110/// so the tree prefix below a boundary is exactly the out-of-window
111/// batch, the property the slide rides).
112///
113/// ```
114/// use kevy_index::{IndexValue, WindowShape, window_bound};
115/// // the same boundary, expressed in each tree's value space
116/// assert_eq!(window_bound(7, WindowShape::PlainI64), IndexValue::I64(7));
117/// assert!(matches!(window_bound(7, WindowShape::CompositeLed), IndexValue::Str(b) if b.len() == 8));
118/// ```
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120#[non_exhaustive]
121pub enum WindowShape {
122 /// `INDEX <wcol> range` — the tree's values ARE the window column.
123 ///
124 /// ```
125 /// use kevy_index::{IndexValue, WindowShape};
126 /// assert_eq!(IndexValue::I64(1_700).window_value(WindowShape::PlainI64), Some(1_700));
127 /// ```
128 PlainI64,
129 /// `ORDERPATH` led by the window column ascending — the values
130 /// are composite byte strings whose first 8 bytes encode it.
131 ///
132 /// ```
133 /// use kevy_index::{IndexValue, WindowShape, order_key, ValType};
134 /// let mut composite = order_key(ValType::I64, b"1700").expect("an i64");
135 /// composite.extend_from_slice(b"trailing columns");
136 /// let v = IndexValue::Str(composite);
137 /// assert_eq!(v.window_value(WindowShape::CompositeLed), Some(1_700));
138 /// ```
139 CompositeLed,
140}
141
142/// What an audit needs to tell "slid out on purpose" from "lost".
143///
144/// A row whose window value sits below `boundary` is absent from the
145/// hot tree by design — but only if it actually reached a cold
146/// segment. Position alone cannot distinguish the two, and a row lost
147/// between the two structures sits below the boundary exactly like a
148/// legitimate one. So the audit carries the cold side's own count and
149/// checks the identity instead:
150///
151/// missing = (rows below the boundary that should be indexed)
152/// - (live cold entries below it)
153///
154/// One `u64` out of the segment scope, no per-row cold lookup, and the
155/// answer is evidence rather than assumption.
156///
157/// ```
158/// use kevy_index::{WindowAudit, WindowShape};
159/// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 42);
160/// assert_eq!((a.boundary, a.cold_live), (1_000, 42));
161/// ```
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163#[non_exhaustive]
164pub struct WindowAudit {
165 /// Bucket-aligned eviction boundary: entries below it are cold.
166 ///
167 /// ```
168 /// use kevy_index::{IndexValue, WindowAudit, WindowShape};
169 /// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 0);
170 /// // a row whose window value sits below the boundary belongs to the cold side
171 /// let v = IndexValue::I64(999).window_value(a.shape).expect("plain i64");
172 /// assert!(v < a.boundary);
173 /// ```
174 pub boundary: i64,
175 /// Which tree shape the boundary lives in.
176 ///
177 /// ```
178 /// use kevy_index::{WindowAudit, WindowShape, window_bound};
179 /// let a = WindowAudit::new(5, WindowShape::CompositeLed, 0);
180 /// // the shape picks the value space the boundary is compared in
181 /// assert_eq!(window_bound(a.boundary, a.shape), window_bound(5, WindowShape::CompositeLed));
182 /// ```
183 pub shape: WindowShape,
184 /// Live (non-tombstoned) cold entries below `boundary`.
185 ///
186 /// ```
187 /// use kevy_index::{WindowAudit, WindowShape};
188 /// let below_boundary_should_index = 50u64;
189 /// let a = WindowAudit::new(1_000, WindowShape::PlainI64, 48);
190 /// // the audit's identity: rows below the boundary minus live cold entries
191 /// assert_eq!(below_boundary_should_index - a.cold_live, 2, "two rows were lost");
192 /// ```
193 pub cold_live: u64,
194}
195
196impl WindowAudit {
197 /// The audit for a tree of `shape` whose eviction boundary is
198 /// `boundary`, with `cold_live` live cold entries below it.
199 ///
200 /// ```
201 /// use kevy_index::{WindowAudit, WindowShape};
202 /// assert_eq!(WindowAudit::new(0, WindowShape::CompositeLed, 0).shape, WindowShape::CompositeLed);
203 /// ```
204 pub fn new(boundary: i64, shape: WindowShape, cold_live: u64) -> WindowAudit {
205 WindowAudit { boundary, shape, cold_live }
206 }
207}
208
209pub(crate) fn window_value_of(v: &IndexValue, shape: WindowShape) -> Option<i64> {
210 match (shape, v) {
211 (WindowShape::PlainI64, IndexValue::I64(i)) => Some(*i),
212 (WindowShape::CompositeLed, IndexValue::Str(b)) => {
213 let raw = u64::from_be_bytes(b.get(..8)?.try_into().ok()?);
214 Some((raw ^ (1 << 63)) as i64)
215 }
216 _ => None,
217 }
218}
219
220/// The below-boundary bound for `scan_below` / `split_off_below`,
221/// under `shape`. For the composite shape the bound is the target's
222/// bare 8-byte first component: any key whose first component sorts
223/// below it is below the bound, and a key EQUAL in those 8 bytes is
224/// longer and therefore above it — "first column < target", exactly.
225///
226/// ```
227/// use kevy_index::{IndexValue, ValType, WindowShape, order_key, window_bound};
228/// let bound = window_bound(100, WindowShape::CompositeLed);
229/// let mut at_100 = order_key(ValType::I64, b"100").expect("an i64");
230/// at_100.extend_from_slice(b"rest");
231/// let mut at_99 = order_key(ValType::I64, b"99").expect("an i64");
232/// at_99.extend_from_slice(b"rest");
233/// assert!(IndexValue::Str(at_99) < bound && bound < IndexValue::Str(at_100));
234/// ```
235pub fn window_bound(target: i64, shape: WindowShape) -> IndexValue {
236 match shape {
237 WindowShape::PlainI64 => IndexValue::I64(target),
238 WindowShape::CompositeLed => {
239 IndexValue::Str(((target as u64) ^ (1 << 63)).to_be_bytes().to_vec())
240 }
241 }
242}
243
244/// Encode a row's stored `VALUES` into a cold entry's payload:
245/// `[n u32][per value: 0 | 1‖len u32‖bytes]`, little-endian. An index
246/// that declared no values encodes the EMPTY payload — byte-identical
247/// to the a-train's segments, which carried none.
248///
249/// ```
250/// use kevy_index::{decode_seg_values, encode_seg_values};
251/// let payload = encode_seg_values(&[Some(b"kyoto"), None]);
252/// assert_eq!(decode_seg_values(&payload), Some(vec![Some(b"kyoto".to_vec()), None]));
253/// assert!(encode_seg_values(&[]).is_empty());
254/// ```
255pub fn encode_seg_values(values: &[Option<&[u8]>]) -> Vec<u8> {
256 if values.is_empty() {
257 return Vec::new();
258 }
259 let mut out = Vec::new();
260 out.extend_from_slice(&(values.len() as u32).to_le_bytes());
261 for v in values {
262 match v {
263 None => out.push(0),
264 Some(b) => {
265 out.push(1);
266 out.extend_from_slice(&(b.len() as u32).to_le_bytes());
267 out.extend_from_slice(b);
268 }
269 }
270 }
271 out
272}
273
274/// Decode a cold entry's payload back to its stored values. The empty
275/// payload is the no-values shape; `None` on any malformed frame — a
276/// corrupt payload is a refusal upstream, never a guessed row.
277///
278/// ```
279/// use kevy_index::{decode_seg_values, encode_seg_values};
280/// assert_eq!(decode_seg_values(&[]), Some(vec![]));
281/// let mut payload = encode_seg_values(&[Some(b"v")]);
282/// payload.push(0);
283/// assert_eq!(decode_seg_values(&payload), None, "trailing bytes are a refusal");
284/// ```
285pub fn decode_seg_values(payload: &[u8]) -> Option<Vec<Option<Vec<u8>>>> {
286 if payload.is_empty() {
287 return Some(Vec::new());
288 }
289 let n = u32::from_le_bytes(payload.get(..4)?.try_into().ok()?) as usize;
290 let mut at = 4usize;
291 // A count out of the payload is a claim: every entry costs at least
292 // its one-byte tag, so `len` bytes cannot honour more than `len` of
293 // them. Unbounded, a u32 count reserves up to 4.29e9 elements here.
294 let mut out = Vec::with_capacity(n.min(payload.len()));
295 for _ in 0..n {
296 match payload.get(at)? {
297 0 => {
298 out.push(None);
299 at += 1;
300 }
301 1 => {
302 let len =
303 u32::from_le_bytes(payload.get(at + 1..at + 5)?.try_into().ok()?) as usize;
304 out.push(Some(payload.get(at + 5..at + 5 + len)?.to_vec()));
305 at += 5 + len;
306 }
307 _ => return None,
308 }
309 }
310 (at == payload.len()).then_some(out)
311}
312
313/// Escape-and-terminate one component into `out`.
314fn frame_into(out: &mut Vec<u8>, b: &[u8]) {
315 for &c in b {
316 if c == 0 {
317 out.push(0);
318 out.push(0xFF);
319 } else {
320 out.push(c);
321 }
322 }
323 out.push(0);
324 out.push(0);
325}
326
327/// Read one framed component; returns (component, rest-after-frame).
328fn unframe(b: &[u8]) -> Option<(Vec<u8>, &[u8])> {
329 let mut out = Vec::new();
330 let mut i = 0;
331 while i < b.len() {
332 if b[i] != 0 {
333 out.push(b[i]);
334 i += 1;
335 continue;
336 }
337 match b.get(i + 1)? {
338 0xFF => {
339 out.push(0);
340 i += 2;
341 }
342 0x00 => return Some((out, &b[i + 2..])),
343 _ => return None,
344 }
345 }
346 None
347}
348
349/// The cold-membership bloom: rows whose entries were evicted into
350/// segments insert here; the write path consults it before spending a
351/// tombstone. A false positive costs one stray tombstone entry
352/// (harmless); a false negative cannot happen, which is the property
353/// correctness rides on.
354///
355/// ```
356/// use kevy_index::ColdBloom;
357/// let mut bloom = ColdBloom::new(1_000);
358/// bloom.insert(b"user:1");
359/// assert!(bloom.contains(b"user:1"), "no false negatives");
360/// let strays = (0..1_000).filter(|i| bloom.contains(format!("other:{i}").as_bytes())).count();
361/// assert!(strays < 20, "false positives stay rare");
362/// ```
363#[derive(Debug)]
364pub struct ColdBloom {
365 bits: Vec<u64>,
366 k: u32,
367}
368
369impl ColdBloom {
370 /// Sized for ~10 bits per expected item (k=7 → ~1% false-positive
371 /// rate at capacity; degrades gracefully past it).
372 pub fn new(expected_items: usize) -> Self {
373 let words = (expected_items.max(64) * 10 / 64).next_power_of_two();
374 Self { bits: vec![0u64; words], k: 7 }
375 }
376
377 /// Record that `item` has at least one cold entry.
378 pub fn insert(&mut self, item: &[u8]) {
379 let (h1, h2) = Self::hashes(item);
380 let nbits = (self.bits.len() * 64) as u64;
381 for i in 0..self.k as u64 {
382 let bit = h1.wrapping_add(i.wrapping_mul(h2)) % nbits;
383 self.bits[(bit / 64) as usize] |= 1 << (bit % 64);
384 }
385 }
386
387 /// Whether `item` MAY have a cold entry (false positives allowed,
388 /// false negatives never).
389 pub fn contains(&self, item: &[u8]) -> bool {
390 let (h1, h2) = Self::hashes(item);
391 let nbits = (self.bits.len() * 64) as u64;
392 (0..self.k as u64).all(|i| {
393 let bit = h1.wrapping_add(i.wrapping_mul(h2)) % nbits;
394 self.bits[(bit / 64) as usize] & (1 << (bit % 64)) != 0
395 })
396 }
397
398 /// FNV-1a under two seeds — double hashing derives the k probes.
399 fn hashes(item: &[u8]) -> (u64, u64) {
400 let fnv = |seed: u64| {
401 let mut h = seed;
402 for &b in item {
403 h ^= b as u64;
404 h = h.wrapping_mul(0x100000001b3);
405 }
406 h
407 };
408 (fnv(0xcbf29ce484222325), fnv(0x84222325cbf29ce4) | 1)
409 }
410}
411
412#[cfg(test)]
413#[path = "segcold_tests.rs"]
414mod tests;
415
416impl IndexValue {
417 /// The order-preserving bytes of one value — [`crate::order_key`]'s
418 /// transform, taken from an already-coerced [`IndexValue`]. The pin
419 /// test holds the two in lockstep.
420 ///
421 /// ```
422 /// use kevy_index::IndexValue;
423 /// assert!(IndexValue::I64(-1).order_bytes() < IndexValue::I64(1).order_bytes());
424 /// ```
425 pub fn order_bytes(&self) -> Vec<u8> {
426 value_order_bytes(self)
427 }
428
429 /// The window-column value a tree entry carries, under `shape`.
430 /// `None` = the entry cannot carry one (wrong variant / short bytes) —
431 /// the caller treats the tree as having no boundary to advance.
432 ///
433 /// ```
434 /// use kevy_index::{IndexValue, WindowShape};
435 /// assert_eq!(IndexValue::I64(42).window_value(WindowShape::PlainI64), Some(42));
436 /// assert_eq!(IndexValue::I64(42).window_value(WindowShape::CompositeLed), None);
437 /// ```
438 pub fn window_value(&self, shape: WindowShape) -> Option<i64> {
439 window_value_of(self, shape)
440 }
441}