1#![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#[derive(Debug, Default)]
31pub(crate) struct TableReg {
32 pub(crate) catalog: RwLock<TableCatalog>,
33 pub(crate) advise: Mutex<AdviseLog>,
37}
38
39const SPOTCHECK_ROWS: usize = 64;
41
42#[cfg(feature = "persist")]
43const SIDECAR: &str = "table-catalog.meta";
44
45#[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 pub fn table_declare(&self, spec: TableSpec) -> KevyResult<()> {
61 #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
65 crate::ops_index_sync::tier_floor_check(&self.shards)?;
66 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 {
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 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 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 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() })
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 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 #[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 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
278fn strip_err(e: &str) -> &str {
281 e.strip_prefix("ERR ").unwrap_or(e)
282}
283
284fn 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 _ => 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 f[4] += below.saturating_sub(window.map_or(0, |w| w.cold_live));
353 f
354}
355
356fn 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
427fn 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}