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