Skip to main content

kevy_embedded/
ops_table.rs

1//! Embedded `TABLE.*` capability — the declaration
2//! object, the compile call and the verify sweep. The grammar, the
3//! validation and [`kevy_index::compile_table`] all live in
4//! `kevy-index`: ONE implementation shared with the server, so the two
5//! engines cannot compile a table differently (the IDX.CREATE parity
6//! lesson; the dispatch oracle byte-compares the wire faces anyway).
7
8// The sidecar IS the catalog's persistence — `boot` reads it and a
9// directory without one "boots empty". So a rename that fails loses
10// the index definitions at the next start, after the command that
11// created them has already replied OK. That is a gap, not a
12// non-event, and it is written up as an open question rather than
13// silently accepted here: .claude/OPEN-QUESTIONS-6.4.md §3.
14#![expect(
15    clippy::let_underscore_must_use,
16    reason = "the catalog has no other home; see .claude/OPEN-QUESTIONS-6.4.md"
17)]
18
19use std::sync::{Mutex, RwLock};
20
21use kevy_index::{
22    AdviseLog, IndexVerify, TableCatalog, TableEnsure, TableSpec, TableVerify, compile_table,
23};
24
25use crate::store::{Store, lock_write};
26use crate::{KevyError, KevyResult};
27
28/// Store-level table state (declarations only — a table's runtime
29/// footprint is its compiled indexes in the index registry).
30#[derive(Debug, Default)]
31pub(crate) struct TableReg {
32    pub(crate) catalog: RwLock<TableCatalog>,
33    /// The refusal log (the auto-declaration loop's observation
34    /// face) — fed by the typed query API's refusals, rendered by
35    /// [`Store::idx_advise`], cleared on every catalog mutation.
36    pub(crate) advise: Mutex<AdviseLog>,
37}
38
39/// Rows the per-shard column spot check samples (mirrors the server).
40const SPOTCHECK_ROWS: usize = 64;
41
42#[cfg(feature = "persist")]
43const SIDECAR: &str = "table-catalog.meta";
44
45/// One `TABLE.VERIFY` result: per compiled index its name + six
46/// counters (entries, bytes, coerce_failures, duplicates, drift,
47/// checked), plus the `(rows, type_mismatches)` spot-check pair.
48/// The legacy verify shape: six unnamed counters per index and two for
49/// the spot check. Kept for semver; superseded by [`TableVerify`],
50/// whose fields carry the names and time semantics these arrays never
51/// could (dogfood F10).
52#[deprecated(since = "4.1.0", note = "use `table_verify_report`, whose fields are named")]
53pub type TableVerifyReport = (Vec<(Vec<u8>, [u64; 6])>, [u64; 2]);
54
55impl Store {
56    /// `TABLE.DECLARE` equivalent: admit the declaration, then build
57    /// every compiled index synchronously. Atomic against catalog
58    /// errors: names are dry-run against a catalog clone first, so a
59    /// collision installs nothing.
60    pub fn table_declare(&self, spec: TableSpec) -> KevyResult<()> {
61        // Tiering floor refusal — the same precheck IDX.CREATE runs,
62        // moved ahead of the catalog mutation so a refused declare
63        // installs nothing.
64        #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
65        crate::ops_index_sync::tier_floor_check(&self.shards)?;
66        // compile_table validates for itself — a bad spec is a named
67        // refusal here, never a panic downstream (dogfood F9).
68        let compiled = compile_table(&spec).map_err(KevyError::InvalidInput)?;
69        {
70            let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
71            if g.get(&spec.name).is_some() {
72                return Err(KevyError::InvalidInput("table already exists".into()));
73            }
74        }
75        // Dry-run the compiled specs against a clone of the index
76        // catalog — the server admits into a clone and installs once;
77        // this is the same all-or-nothing shape.
78        {
79            let g = self.indexes.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
80            let mut probe = g.1.clone();
81            for ispec in &compiled {
82                probe
83                    .create(ispec.clone())
84                    .map_err(|e| KevyError::InvalidInput(strip_err(e).into()))?;
85            }
86        }
87        {
88            let mut g =
89                self.tables.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
90            g.create(spec).map_err(|e| KevyError::InvalidInput(strip_err(&e).into()))?;
91        }
92        self.persist_table_sidecar();
93        for ispec in compiled {
94            self.register_spec(ispec)?;
95        }
96        self.advise_clear();
97        Ok(())
98    }
99
100    /// `TABLE.ENSURE` equivalent — the boot verb.
101    ///
102    /// The dogfood report's F8.2: declaring at boot is the steady
103    /// state, and plain `table_declare` punishes it — a re-declare is
104    /// an error, so the obvious code (declare, log the error at debug)
105    /// keeps running against whatever shape the *first* boot declared,
106    /// forever, silently. `ensure` gives the boot path its true verb:
107    /// an identical spec is a no-op success, a changed spec is a named
108    /// refusal carrying what changed — never a silent rebuild, because
109    /// a rebuild is a full backfill and must be asked for by name
110    /// ([`Self::table_replace`]).
111    pub fn table_ensure(&self, spec: TableSpec) -> KevyResult<TableEnsure> {
112        let existing = {
113            let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
114            g.get(&spec.name).cloned()
115        };
116        match existing {
117            None => {
118                self.table_declare(spec)?;
119                Ok(TableEnsure::Created)
120            }
121            Some(cur) if cur.sans_auto() == spec => Ok(TableEnsure::Unchanged),
122            Some(cur) => {
123                Err(KevyError::InvalidInput(kevy_index::spec_diff(&cur.sans_auto(), &spec)))
124            }
125        }
126    }
127
128    /// `TABLE.REPLACE` equivalent — drop and redeclare, rebuilding
129    /// every compiled index from the rows. Explicitly named because it
130    /// is a full backfill: the cost is asked for, not implied. The new
131    /// spec is compiled (and therefore validated) *before* the old
132    /// table is dropped, so a bad replacement leaves the old one
133    /// standing.
134    pub fn table_replace(&self, spec: TableSpec) -> KevyResult<()> {
135        compile_table(&spec).map_err(KevyError::InvalidInput)?;
136        self.table_drop(&spec.name);
137        self.table_declare(spec)
138    }
139
140    /// `TABLE.DROP` equivalent — drops the table AND its compiled
141    /// indexes; `false` if absent.
142    pub fn table_drop(&self, name: &[u8]) -> bool {
143        let compiled: Vec<Vec<u8>> = {
144            let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
145            g.get(name)
146                .map(|s| {
147                    compile_table(s)
148                        .map(|c| c.into_iter().map(|i| i.name).collect())
149                        .unwrap_or_default() // catalog entries were admitted validated
150                })
151                .unwrap_or_default()
152        };
153        let hit = {
154            let mut g =
155                self.tables.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
156            g.drop_table(name)
157        };
158        if hit {
159            for iname in &compiled {
160                self.idx_drop(iname);
161            }
162            self.persist_table_sidecar();
163            self.advise_clear();
164        }
165        hit
166    }
167
168    /// Declared tables, declaration order.
169    pub fn table_list(&self) -> Vec<TableSpec> {
170        let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
171        g.iter().cloned().collect()
172    }
173
174    /// `TABLE.VERIFY` equivalent: each compiled index's full drift
175    /// recheck (the IDX.VERIFY discipline — composites re-derive their
176    /// byte encoding) plus a bounded column-type spot check, both
177    /// through the no-promote peek.
178    #[deprecated(since = "4.1.0", note = "use `table_verify_report`, whose fields are named")]
179    #[allow(deprecated)]
180    pub fn table_verify(&self, name: &[u8]) -> KevyResult<TableVerifyReport> {
181        let r = self.table_verify_report(name)?;
182        Ok((
183            r.per_index
184                .into_iter()
185                .map(|i| {
186                    (
187                        i.name,
188                        [
189                            i.entries,
190                            i.approx_bytes,
191                            i.coerce_failures,
192                            i.duplicates,
193                            i.drift,
194                            i.checked,
195                        ],
196                    )
197                })
198                .collect(),
199            [r.spot_rows, r.spot_type_mismatches],
200        ))
201    }
202
203    /// `TABLE.VERIFY`, with every counter named and its time semantics
204    /// documented on the field (see [`IndexVerify`]).
205    pub fn table_verify_report(&self, name: &[u8]) -> KevyResult<TableVerify> {
206        let spec = {
207            let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
208            g.get(name).cloned()
209        }
210        .ok_or_else(|| KevyError::NotFound("no such table".into()))?;
211        let compiled = compile_table(&spec).map_err(KevyError::InvalidInput)?;
212        let mut per_index: Vec<(Vec<u8>, [u64; 10])> =
213            compiled.iter().map(|i| (i.name.clone(), [0u64; 10])).collect();
214        let mut spot = [0u64; 2];
215        for shard in self.shards.iter() {
216            let mut g = lock_write(shard);
217            let inner = &mut *g;
218            crate::ops_index_sync::sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
219            for (slot, ispec) in per_index.iter_mut().zip(&compiled) {
220                shard_index_counts(inner, ispec, &mut slot.1);
221            }
222            let (r, m) = shard_spot_check(&mut inner.store, &spec);
223            spot[0] += r;
224            spot[1] += m;
225        }
226        Ok(TableVerify {
227            per_index: per_index
228                .into_iter()
229                .map(|(name, s)| IndexVerify {
230                    name,
231                    entries: s[0],
232                    approx_bytes: s[1],
233                    coerce_failures: s[2],
234                    duplicates: s[3],
235                    drift: s[4],
236                    checked: s[5],
237                    excluded: s[6],
238                    absent: s[7],
239                    rows: s[8],
240                    missing: s[9],
241                })
242                .collect(),
243            spot_rows: spot[0],
244            spot_type_mismatches: spot[1],
245        })
246    }
247
248    #[cfg(feature = "persist")]
249    pub(crate) fn table_boot(&self) {
250        let Some(dir) = &self.config.data_dir else { return };
251        if let Ok(text) = std::fs::read_to_string(dir.join(SIDECAR))
252            && let Some(cat) = TableCatalog::from_sidecar(&text)
253            && !cat.is_empty()
254        {
255            let mut g =
256                self.tables.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
257            *g = cat;
258        }
259    }
260
261    #[cfg(not(feature = "persist"))]
262    pub(crate) fn table_boot(&self) {}
263
264    #[cfg(feature = "persist")]
265    pub(crate) fn persist_table_sidecar(&self) {
266        let Some(dir) = &self.config.data_dir else { return };
267        let g = self.tables.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
268        let tmp = dir.join("table-catalog.meta.tmp");
269        if std::fs::write(&tmp, g.to_sidecar()).is_ok() {
270            let _ = std::fs::rename(&tmp, dir.join(SIDECAR));
271        }
272    }
273
274    #[cfg(not(feature = "persist"))]
275    pub(crate) fn persist_table_sidecar(&self) {}
276}
277
278/// Catalog errors carry a leading `ERR ` for the wire; the typed
279/// `KevyError` face re-adds it, so strip here to avoid `ERR ERR`.
280fn strip_err(e: &str) -> &str {
281    e.strip_prefix("ERR ").unwrap_or(e)
282}
283
284/// One shard's contribution to one compiled index's verify counters —
285/// both directions.
286///
287/// index→row (`drift`): every held entry re-derives from its row.
288/// row→index: every prefix row classifies against the spec,
289/// with the cause kept — `absent` (NULL, by design), `coerce_failures`
290/// (present-but-wrong-type, fresh: the 4.0 lifetime counter of this
291/// name also swallowed absences), `excluded` (composite oversize, the
292/// silent two-row gap of dogfood F8/F9, now a named number), and
293/// `missing` (derives a value, has no entry — the class a drift walk
294/// structurally cannot see, and the one behind F13/F14's forgotten
295/// writers).
296///
297/// Layout: [entries, bytes, coerce_fresh, duplicates, drift, checked,
298///          excluded, absent, rows, missing].
299/// The row→index half of the walk, under the no-promote peek: every
300/// prefix row classified by cause (the server's `classify_prefix_rows`
301/// is the byte-parity twin).
302/// Returns `[coerce_fresh, excluded, absent, rows, missing]`.
303/// `window` carries the cold side's own count — the twin of the
304/// server's `cmd_table_verify::classify_prefix_rows`, including its
305/// reason: a row that slid is absent from the hot entries by design,
306/// but only if it actually reached a segment, so the rows below the
307/// boundary are reconciled against the live cold entries instead of
308/// being exempted for sitting there.
309fn classify_prefix_rows(
310    s: &mut kevy_store::Store,
311    spec: &kevy_index::IndexSpec,
312    row_keys: &[Vec<u8>],
313    indexed: &std::collections::HashSet<&[u8]>,
314    window: Option<kevy_index::WindowAudit>,
315) -> [u64; 5] {
316    let names = spec.scalar_read_names();
317    let w = spec.primary_width();
318    let mut f = [0u64; 5];
319    let mut below = 0u64;
320    for key in row_keys {
321        f[3] += 1;
322        let cls = match s.peek_hash_fields(key, &names[..w]) {
323            Ok(Some(vals)) => spec.classify_scalar(&vals),
324            // A non-hash or vanished row has no columns: NULL row.
325            _ => kevy_index::RowDerivation::Absent,
326        };
327        match cls {
328            kevy_index::RowDerivation::Indexed(_) => {
329                let slid = window.is_some_and(|wa| {
330                    let v = match s.peek_hash_fields(key, &names[..w]) {
331                        Ok(Some(vals)) => spec.derive_scalar(&vals),
332                        _ => None,
333                    };
334                    v.and_then(|v| kevy_index::window_value_of(&v, wa.shape))
335                        .is_some_and(|wv| wv < wa.boundary)
336                });
337                if !indexed.contains(key.as_slice()) {
338                    if slid {
339                        below += 1;
340                    } else {
341                        f[4] += 1;
342                    }
343                }
344            }
345            kevy_index::RowDerivation::CoerceFailed => f[0] += 1,
346            kevy_index::RowDerivation::Oversize => f[1] += 1,
347            kevy_index::RowDerivation::Absent => f[2] += 1,
348        }
349    }
350    // Every row that slid should have an entry waiting for it; the
351    // shortfall is the rows that fell between the tree and the segment.
352    f[4] += below.saturating_sub(window.map_or(0, |w| w.cold_live));
353    f
354}
355
356/// The window boundary a `missing` count must not fault rows against:
357/// a row below it slid into a cold segment on purpose. `None` when the
358/// path is unwindowed or has not slid yet (and always on wasm, where
359/// nothing slides).
360fn hot_floor_of(
361    inner: &crate::store_inner::Inner,
362    name: &[u8],
363    ty: kevy_index::ValType,
364) -> Option<kevy_index::WindowAudit> {
365    #[cfg(not(target_arch = "wasm32"))]
366    {
367        inner
368            .idx_segs
369            .windows
370            .iter()
371            .find(|(n, _)| n.as_slice() == name)
372            .and_then(|(_, w)| w.audit(ty))
373    }
374    #[cfg(target_arch = "wasm32")]
375    {
376        let _ = (inner, name, ty);
377        None
378    }
379}
380
381fn shard_index_counts(
382    inner: &mut crate::store_inner::Inner,
383    ispec: &kevy_index::IndexSpec,
384    sums: &mut [u64; 10],
385) {
386    let Some((spec, seg)) = inner.idx_segs.segs.iter().find(|(s, _)| s.name == ispec.name) else {
387        return;
388    };
389    let stats = seg.stats();
390    let mut entries: Vec<(Vec<u8>, kevy_index::IndexValue)> = Vec::new();
391    seg.each_entry(|k, v| entries.push((k.to_vec(), v.clone())));
392    let indexed: std::collections::HashSet<&[u8]> =
393        entries.iter().map(|(k, _)| k.as_slice()).collect();
394    let spec = spec.clone();
395    let mut pat = spec.prefix.clone();
396    pat.push(b'*');
397    let row_keys = inner.store.collect_keys(Some(&pat), None);
398    let window = hot_floor_of(inner, &spec.name, spec.ty);
399    let store = &mut inner.store;
400    let (drift, fresh) = store.peek_scope(|s| {
401        let names = spec.scalar_read_names();
402        let w = spec.primary_width();
403        let mut drift = 0u64;
404        for (key, held) in &entries {
405            let actual = match s.peek_hash_fields(key, &names[..w]) {
406                Ok(Some(vals)) => spec.derive_scalar(&vals),
407                _ => None,
408            };
409            if actual.as_ref() != Some(held) {
410                drift += 1;
411            }
412        }
413        (drift, classify_prefix_rows(s, &spec, &row_keys, &indexed, window))
414    });
415    sums[0] += stats.entries;
416    sums[1] += stats.approx_bytes;
417    sums[2] += fresh[0];
418    sums[3] += stats.duplicates;
419    sums[4] += drift;
420    sums[5] += entries.len() as u64;
421    sums[6] += fresh[1];
422    sums[7] += fresh[2];
423    sums[8] += fresh[3];
424    sums[9] += fresh[4];
425}
426
427/// Sample up to [`SPOTCHECK_ROWS`] rows on one shard: every PRESENT
428/// declared-typed column must coerce (absent = NULL — Law 3; a
429/// non-hash row counts as a row with no columns).
430fn shard_spot_check(store: &mut kevy_store::Store, spec: &TableSpec) -> (u64, u64) {
431    let mut pat = spec.prefix.clone();
432    pat.push(b'*');
433    let keys = store.collect_keys(Some(&pat), Some(SPOTCHECK_ROWS));
434    let names: Vec<&[u8]> = spec.columns.iter().map(|(n, _)| n.as_slice()).collect();
435    store.peek_scope(|s| {
436        let (mut rows, mut mismatches) = (0u64, 0u64);
437        for key in &keys {
438            rows += 1;
439            let Ok(Some(vals)) = s.peek_hash_fields(key, &names) else { continue };
440            for ((_, ty), val) in spec.columns.iter().zip(&vals) {
441                if let Some(raw) = val
442                    && kevy_index::IndexValue::coerce(*ty, raw).is_none()
443                {
444                    mismatches += 1;
445                }
446            }
447        }
448        (rows, mismatches)
449    })
450}