kevy 6.0.0

kevy — a pure-Rust, zero-dependency, Redis-compatible KV server.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Scalar IDX.QUERY / IDX.COUNT / IDX.VERIFY per-shard execution plus
//! the IDX.EXPLAIN / IDX.LIST admin surface.

use kevy_index::{IndexSpec, IndexValue, SegmentStats};
use kevy_store::Store;

use super::args::{KnnArgs, Query, Shape, parse_groups_args};
use super::wire::{encode_hydration_row, encode_value, peek_hydration};
use super::{ST_BADARGS, ST_BUILDING, ST_NOINDEX, ST_OK, ST_OVERBUDGET};
use crate::index_runtime;
use crate::state::Ctx;

enum HitsOrChunk {
    Hits(Vec<(Vec<u8>, IndexValue)>),
    Chunk(Vec<u8>),
    /// A VERIFY snapshot: the segment's held entries plus its stats. The drift
    /// recheck needs the store, which the segment borrow holds, so it runs
    /// after that borrow ends — same shape as `encode_hits_chunk`'s hydration.
    Verify {
        spec: Box<IndexSpec>,
        entries: Vec<(Vec<u8>, IndexValue)>,
        stats: SegmentStats,
        /// `(boundary, shape)` when the path is windowed: rows whose
        /// window value sits below the boundary have legitimately slid
        /// to a cold segment and are absent from the hot entries by
        /// design, so the missing-row sweep must not count them.
        window: Option<kevy_index::WindowAudit>,
    },
}

/// The scalar tail of the IDX.* fan-out: parse the query grammar,
/// answer kind-specific VERIFY stats, else run the range/eq/verify
/// shape against this shard's segment.
pub(super) fn op_query(ctx: &Ctx<'_>, store: &mut Store, argv: &[Vec<u8>], verb: &[u8]) -> Vec<u8> {
    let Some(q) = Query::parse(argv) else {
        return vec![ST_BADARGS];
    };
    // IDX.COUNT applies FILTER (the claused count — the total a
    // claused query's pages would reach, materializing nothing) and
    // refuses every clause it would not apply: SORT/DISTINCT/OFFSET
    // cannot change a count, FACET/FIELDS have nowhere to go, and
    // accepting-then-ignoring any of them would be worse than the
    // refusal.
    if verb.eq_ignore_ascii_case(b"IDX.COUNT") {
        if q.selects() || !q.fields.is_empty() || q.cursor_raw.is_some() {
            return vec![ST_BADARGS];
        }
        if !q.filters.is_empty() {
            return super::query_claused::run_claused_count(ctx, store, &q);
        }
    }
    // Pure grammar, refused before the segment is consulted: the
    // selection clauses re-shape the page, so a resume point in the
    // driving order has nothing to resume.
    if q.cursor_raw.is_some() && q.selects() {
        return super::query_claused::clause_chunk(
            super::query_claused::CURSOR_CLAUSE_CONFLICT,
        );
    }
    if matches!(q.shape, Shape::Verify)
        && let Some(chunk) = verify_kind_stats(ctx, store, &q.name)
    {
        return chunk;
    }
    if q.has_clauses() {
        return super::query_claused::run_claused_query(ctx, store, &q);
    }
    run_scalar_query(ctx, store, &q, verb)
}

/// Aggregate / ANN / text indexes answer VERIFY with their own stats
/// (`None` = scalar kind, fall through to the segment path).
fn verify_kind_stats(ctx: &Ctx<'_>, store: &mut Store, name: &[u8]) -> Option<Vec<u8>> {
    let kind = ctx.state.catalogs.index().and_then(|c| c.get(name).map(|(s, _)| s.kind))?;
    // Each kind answers with its own numbers UNDER ITS OWN NAMES. These
    // used to be four bare u64s that the reducer labelled positionally
    // with the scalar audit's vocabulary — a healthy 3-doc text index
    // answered `coerce_failures 7, duplicates 7` (its postings and
    // token counts), which reads as an integrity warning. The tag byte
    // after the status is what lets the reducer tell the truth. The ann
    // row also carried two facts in one number (`links +
    // rebuild_recommended`); they travel separately now.
    let res = match kind {
        kevy_index::IndexKind::Agg => index_runtime::with_ready_agg(ctx, store, name, |a| {
            let st = a.stats();
            (b'a', vec![st.rows, st.approx_bytes, st.excluded, st.groups])
        }),
        kevy_index::IndexKind::Ann => index_runtime::with_ready_ann(ctx, store, name, |g| {
            let st = g.stats();
            (
                b'v',
                vec![
                    st.vectors,
                    st.approx_bytes,
                    st.tombstones,
                    st.links,
                    u64::from(st.rebuild_recommended),
                ],
            )
        }),
        kevy_index::IndexKind::Text => {
            index_runtime::with_ready_text_segment(ctx, store, name, |_, ts, _, _| {
                let st = ts.stats();
                (b't', vec![st.docs, st.approx_bytes, st.postings, st.tokens])
            })
        }
        _ => return None,
    };
    Some(match res {
        Ok((tag, values)) => {
            let mut chunk = vec![ST_OK, tag];
            for v in values {
                chunk.extend_from_slice(&v.to_le_bytes());
            }
            chunk
        }
        Err(e) if e.as_wire().starts_with("INDEXBUILDING") => vec![ST_BUILDING],
        Err(_) => vec![ST_NOINDEX],
    })
}

/// Range / Eq / scalar-Verify against this shard's segment.
fn run_scalar_query(ctx: &Ctx<'_>, store: &mut Store, q: &Query, verb: &[u8]) -> Vec<u8> {
    let res = index_runtime::with_ready_segment(ctx, store, &q.name, |spec, seg, win| match q
        .shape
    {
        Shape::Range { .. } | Shape::Eq { .. } | Shape::Where(_) => {
            let now = (kevy_store::now_unix_ms() / 1000) as i64;
            let (min, max) = match q.bounds_for(spec, now) {
                Ok(b) => b,
                Err(chunk) => return HitsOrChunk::Chunk(chunk),
            };
            super::probe_window(ctx, &q.name, win, &min);
            scalar_range_or_count(q, verb, spec, seg, win, &min, &max)
        }
        // VERIFY answers "does the index still agree with the keyspace?".
        // The segment cannot be walked and the store re-read at the same time
        // (`with_ready_segment` holds the store), so snapshot the held
        // (key, value) pairs here and do the recheck outside — which is what
        // this arm was always shaped for, except the snapshot was collected,
        // thrown away with `let _ = (...)`, and the drift it was for never
        // computed. That left an O(N) walk plus an O(N) allocation per shard
        // per VERIFY, producing nothing, while `verb_meta` and the docs
        // advertised a drift statistic the reply did not carry.
        Shape::Verify => {
            let mut entries: Vec<(Vec<u8>, IndexValue)> = Vec::new();
            seg.each_entry(|k, v| entries.push((k.to_vec(), v.clone())));
            HitsOrChunk::Verify {
                spec: Box::new(spec.clone()),
                entries,
                stats: seg.stats(),
                window: win.and_then(|w| w.audit(spec.ty)),
            }
        }
    });
    match res {
        Ok(HitsOrChunk::Chunk(chunk)) => chunk,
        Ok(HitsOrChunk::Hits(hits)) => encode_hits_chunk(store, &hits, &q.fields),
        Ok(HitsOrChunk::Verify { spec, entries, stats, window }) => {
            encode_verify_chunk(store, &spec, &entries, &stats, window)
        }
        Err(e) if e.as_wire().starts_with("INDEXBUILDING") => vec![ST_BUILDING],
        Err(e) if e.as_wire().starts_with("INDEXOVERBUDGET") => vec![ST_OVERBUDGET],
        Err(_) => vec![ST_NOINDEX],
    }
}

/// The range/count body: hot tree plus — on a windowed index that has
/// evicted — the cold segments' half. A corrupt cold segment answers
/// ST_NOINDEX, never a partial number.
fn scalar_range_or_count(
    q: &Query,
    verb: &[u8],
    spec: &kevy_index::IndexSpec,
    seg: &kevy_index::Segment,
    win: Option<&index_runtime::WindowRt>,
    min: &IndexValue,
    max: &IndexValue,
) -> HitsOrChunk {
    let cold = win.filter(|w| w.has_cold());
    if verb.eq_ignore_ascii_case(b"IDX.COUNT") {
        let cold_n = match cold.map(|w| w.cold_count(spec.ty, min, max)).transpose() {
            Ok(n) => n.unwrap_or(0),
            Err(_) => return HitsOrChunk::Chunk(vec![ST_NOINDEX]),
        };
        let mut chunk = vec![ST_OK];
        chunk.extend_from_slice(&(seg.count(min, max) + cold_n).to_le_bytes());
        return HitsOrChunk::Chunk(chunk);
    }
    let cursor = q.cursor(spec.ty);
    let (hits, _) = seg.range(min, max, cursor.as_ref(), q.limit);
    match cold.map(|w| w.cold_hits(spec.ty, min, max, cursor.as_ref(), q.limit)).transpose() {
        Ok(None) => HitsOrChunk::Hits(hits),
        Ok(Some(cold_hits)) => HitsOrChunk::Hits(merge_cold(hits, cold_hits, q.limit)),
        Err(_) => HitsOrChunk::Chunk(vec![ST_NOINDEX]),
    }
}

/// Merge the hot page with the cold hits: both ascend by
/// `(value, key)` and both already resumed past any cursor (each
/// source's own walk skips it), so the merge is a plain two-way
/// ascending zip cut to `limit`.
fn merge_cold(
    hot: Vec<(Vec<u8>, IndexValue)>,
    cold: Vec<(Vec<u8>, IndexValue)>,
    limit: usize,
) -> Vec<(Vec<u8>, IndexValue)> {
    let mut out = Vec::with_capacity(hot.len() + cold.len());
    let (mut h, mut c) = (hot.into_iter().peekable(), cold.into_iter().peekable());
    while out.len() < limit {
        let take_cold = match (h.peek(), c.peek()) {
            (None, None) => break,
            (Some(_), None) => false,
            (None, Some(_)) => true,
            (Some((hk, hv)), Some((ck, cv))) => (cv, ck) < (hv, hk),
        };
        let (k, v) = if take_cold { c.next() } else { h.next() }.expect("peeked");
        out.push((k, v));
    }
    out
}

/// Hydration happens OUTSIDE the segment borrow: the hits' rows live
/// on this shard, plain hash reads.
fn encode_hits_chunk(
    store: &mut Store,
    hits: &[(Vec<u8>, IndexValue)],
    fields: &[Vec<u8>],
) -> Vec<u8> {
    let mut chunk = vec![ST_OK];
    chunk.extend_from_slice(&(hits.len() as u32).to_le_bytes());
    // Hydration rows prefetched as ONE batched page (cold rows
    // coalesce into one submission), then encoded in hit order.
    let keys: Vec<&[u8]> = hits.iter().map(|(k, _)| k.as_slice()).collect();
    let rows = peek_hydration(store, &keys, fields);
    for (i, (k, v)) in hits.iter().enumerate() {
        chunk.extend_from_slice(&(k.len() as u32).to_le_bytes());
        chunk.extend_from_slice(k);
        encode_value(&mut chunk, v);
        encode_hydration_row(&mut chunk, fields.len(), &rows[i]);
    }
    chunk
}

/// IDX.EXPLAIN <name> <shape…> — the exact IDX.QUERY parse,
/// ZERO execution. Chunk: [ST_OK][building u8][entries u64 LE]
/// [shape byte] — kind/plan text assemble on the origin from the
/// catalog spec.
pub(super) fn op_explain(ctx: &Ctx<'_>, store: &mut Store, argv: &[Vec<u8>]) -> Vec<u8> {
    let Some(cat) = ctx.state.catalogs.index() else {
        return vec![ST_NOINDEX];
    };
    let name = argv.get(1).map(Vec::as_slice).unwrap_or(b"");
    let Some(spec) = cat.iter().map(|(s, _)| s).find(|s| s.name.as_slice() == name) else {
        return vec![ST_NOINDEX];
    };
    // Dry-run the same parses IDX.QUERY would run — arity/shape errors
    // surface here without touching the segment.
    let shape = argv.get(2).map(Vec::as_slice).unwrap_or(b"");
    let mut qargv = argv.to_vec();
    qargv[0] = b"IDX.QUERY".to_vec();
    let parsed = if name.eq_ignore_ascii_case(b"HYBRID") {
        // IDX.EXPLAIN HYBRID <text_idx> MATCH … — spec position 1 is
        // the mode, not an index; dry-run the hybrid parse.
        super::args::HybridArgs::parse(&qargv).is_some()
    } else if shape.eq_ignore_ascii_case(b"MATCH") {
        super::args::MatchArgs::parse(&qargv).is_some()
    } else if shape.eq_ignore_ascii_case(b"KNN") {
        KnnArgs::parse(&qargv).is_some()
    } else if shape.eq_ignore_ascii_case(b"GROUP") || shape.eq_ignore_ascii_case(b"GROUPS") {
        shape.eq_ignore_ascii_case(b"GROUP") || parse_groups_args(&qargv).is_some()
    } else {
        Query::parse(&qargv).is_some()
    };
    if !parsed {
        return vec![ST_BADARGS];
    }
    let building = index_runtime::segment_building(ctx, store, &spec.name);
    let entries = kind_entries(ctx, store, spec.kind, &spec.name);
    let mut chunk = vec![ST_OK, u8::from(building)];
    chunk.extend_from_slice(&entries.to_le_bytes());
    chunk.push(shape.first().copied().unwrap_or(b'?').to_ascii_uppercase());
    chunk
}

/// This shard's live row count for one index, by kind.
fn kind_entries(ctx: &Ctx<'_>, store: &mut Store, kind: kevy_index::IndexKind, name: &[u8]) -> u64 {
    match kind {
        kevy_index::IndexKind::Agg => {
            index_runtime::with_ready_agg(ctx, store, name, |a| a.rows()).unwrap_or_default()
        }
        kevy_index::IndexKind::Ann => {
            index_runtime::with_ready_ann(ctx, store, name, |g| g.vectors()).unwrap_or_default()
        }
        kevy_index::IndexKind::Text => {
            index_runtime::with_ready_text_segment(ctx, store, name, |_, t, _, _| t.docs())
                .unwrap_or_default()
        }
        _ => index_runtime::with_ready_segment(ctx, store, name, |_, s, _| s.stats().entries)
            .unwrap_or_default(),
    }
}

pub(super) fn op_list(ctx: &Ctx<'_>, store: &mut Store) -> Vec<u8> {
    // Chunk: per declared index, this shard's (entries, bytes,
    // coerce_failures, duplicates, building-flag).
    let Some(cat) = ctx.state.catalogs.index() else {
        return vec![ST_OK];
    };
    let mut chunk = vec![ST_OK];
    for (spec, _) in cat.iter() {
        let building = index_runtime::segment_building(ctx, store, &spec.name);
        // (entries, bytes, coerce_failures/postings, duplicates/tokens)
        let quad = if spec.kind == kevy_index::IndexKind::Agg {
            index_runtime::with_ready_agg(ctx, store, &spec.name, |a| {
                let st = a.stats();
                (st.rows, st.approx_bytes, st.excluded, st.groups)
            })
            .unwrap_or_default()
        } else if spec.kind == kevy_index::IndexKind::Ann {
            index_runtime::with_ready_ann(ctx, store, &spec.name, |g| {
                let st = g.stats();
                (st.vectors, st.approx_bytes, st.tombstones, st.links)
            })
            .unwrap_or_default()
        } else if spec.kind == kevy_index::IndexKind::Text {
            index_runtime::with_ready_text_segment(ctx, store, &spec.name, |_, ts, _, _| {
                let st = ts.stats();
                (st.docs, st.approx_bytes, st.postings, st.tokens)
            })
            .unwrap_or_default()
        } else {
            index_runtime::with_ready_segment(ctx, store, &spec.name, |_, seg, _| {
                let st = seg.stats();
                (st.entries, st.approx_bytes, st.coerce_failures, st.duplicates)
            })
            .unwrap_or_default()
        };
        chunk.push(u8::from(building));
        chunk.extend_from_slice(&quad.0.to_le_bytes());
        chunk.extend_from_slice(&quad.1.to_le_bytes());
        chunk.extend_from_slice(&quad.2.to_le_bytes());
        chunk.extend_from_slice(&quad.3.to_le_bytes());
    }
    chunk
}

/// The drift recheck, outside the segment borrow.
///
/// For every key the index holds, re-read its row from the keyspace and
/// re-coerce it exactly as the builder does. Three ways an entry can be
/// wrong, and all three count as drift:
///
///   * the row is gone but the index still holds the key,
///   * the row no longer coerces (the field was overwritten with a value the
///     index's type cannot take),
///   * the row coerces to a DIFFERENT value than the one indexed.
///
/// A write-hook-maintained index should never drift. `IDX.VERIFY` exists so
/// that claim is falsifiable instead of merely asserted — which is why the
/// number has to actually be computed.
fn encode_verify_chunk(
    store: &mut Store,
    spec: &IndexSpec,
    entries: &[(Vec<u8>, IndexValue)],
    stats: &SegmentStats,
    window: Option<kevy_index::WindowAudit>,
) -> Vec<u8> {
    // VERIFY's recheck is a bulk sweep — inside the peek scope a
    // cold row costs one pread and never promotes or marks the gate.
    let mut pattern = spec.prefix.clone();
    pattern.push(b'*');
    let row_keys = store.collect_keys(Some(&pattern), None);
    let indexed: std::collections::HashSet<&[u8]> =
        entries.iter().map(|(k, _)| k.as_slice()).collect();
    let (drift, missing) = store.peek_scope(|s| {
        let mut drift = 0u64;
        for (key, held) in entries {
            match index_runtime::row_value(s, spec, key) {
                index_runtime::RowValue::Value(actual) if &actual == held => {}
                _ => drift += 1,
            }
        }
        // The other direction, from the same classifier TABLE.VERIFY
        // uses — one implementation, so the two faces cannot disagree
        // about what a hole is.
        let cls =
            crate::cmd_table_verify::classify_prefix_rows(s, spec, &row_keys, &indexed, window);
        (drift, cls[4])
    });
    let mut chunk = vec![ST_OK, b's'];
    chunk.extend_from_slice(&stats.entries.to_le_bytes());
    chunk.extend_from_slice(&stats.approx_bytes.to_le_bytes());
    chunk.extend_from_slice(&stats.coerce_failures.to_le_bytes());
    chunk.extend_from_slice(&stats.duplicates.to_le_bytes());
    chunk.extend_from_slice(&drift.to_le_bytes());
    chunk.extend_from_slice(&(entries.len() as u64).to_le_bytes());
    chunk.extend_from_slice(&missing.to_le_bytes());
    chunk
}

#[cfg(test)]
#[path = "query_verify_tests.rs"]
mod verify_tests;