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