kevy_embedded/ops_index_advise.rs
1//! The embedded face of the auto-declaration loop: the same bounded
2//! refusal log the server keeps ([`kevy_index::AdviseLog`] — one
3//! shared implementation, so the two faces cannot observe
4//! differently), fed by the typed query API's refusals and rendered
5//! by [`Store::idx_advise`]. A `#[path]` child of `ops_index.rs`.
6//!
7//! Two face-specific notes. The embedded API has no argv, so samples
8//! are empty and a composite path asked for through
9//! [`Store::idx_query`] is observed as a Range family — when its
10//! suffix is not a column, the catalog withholds the advice, exactly
11//! as the wire face does. And the text MATCH clause surface does not
12//! feed the log yet — its unstored-field refusals belong to a text
13//! declaration form the advice renderer does not speak (the
14//! autodeclare RFC's slice b).
15
16use std::sync::PoisonError;
17
18use kevy_index::AdviseShape;
19
20use crate::store::Store;
21use crate::{KevyError, KevyResult};
22
23/// Feed one windowed query's probe depth (`lower - boundary`) into
24/// the path's cell — the window-narrowing observation. Skipped until
25/// the boundary exists and for bounds outside the window shape; a
26/// per-shard repeat just re-records the same minimum.
27#[cfg(not(target_arch = "wasm32"))]
28pub(crate) fn probe_window(
29 cell: &Option<std::sync::Arc<kevy_index::UsageCell>>,
30 win: &kevy_window::WindowRt,
31 lower: &kevy_index::IndexValue,
32) {
33 let Some(c) = cell else { return };
34 if win.boundary() == i64::MIN {
35 return;
36 }
37 if let Some(v) = lower.window_value(win.shape()) {
38 c.probe(v.saturating_sub(win.boundary()));
39 }
40}
41
42/// One [`Store::idx_advise`] row: how often the family was refused,
43/// the access path it asked for, and the declaration that serves it.
44///
45/// ```
46/// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
47/// assert!(s.idx_advise().is_empty()); // nothing refused yet
48/// # Ok::<(), kevy_embedded::KevyError>(())
49/// ```
50#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub struct IdxAdvice {
53 /// Refusals observed for this family.
54 ///
55 /// ```
56 /// # use kevy_embedded::{Config, IndexValType, IndexValue, Store, TableSpec};
57 /// # let s = Store::open(Config::default())?;
58 /// # let mut t = TableSpec::default();
59 /// # (t.name, t.prefix, t.pk) = (b"ev".to_vec(), b"ev:".to_vec(), b"id".to_vec());
60 /// # t.columns = vec![(b"id".to_vec(), IndexValType::Str), (b"age".to_vec(), IndexValType::I64)];
61 /// # s.table_declare(t)?;
62 /// # let (lo, hi) = (IndexValue::I64(0), IndexValue::I64(99));
63 /// // the table has an `age` column but no index over it
64 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
65 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
66 /// assert_eq!(s.idx_advise()[0].count, 2);
67 /// # Ok::<(), kevy_embedded::KevyError>(())
68 /// ```
69 pub count: u64,
70 /// The access-path name the queries asked for.
71 ///
72 /// ```
73 /// # use kevy_embedded::{Config, IndexValType, IndexValue, Store, TableSpec};
74 /// # let s = Store::open(Config::default())?;
75 /// # let mut t = TableSpec::default();
76 /// # (t.name, t.prefix, t.pk) = (b"ev".to_vec(), b"ev:".to_vec(), b"id".to_vec());
77 /// # t.columns = vec![(b"id".to_vec(), IndexValType::Str), (b"age".to_vec(), IndexValType::I64)];
78 /// # s.table_declare(t)?;
79 /// # let (lo, hi) = (IndexValue::I64(0), IndexValue::I64(99));
80 /// // the table has an `age` column but no index over it
81 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
82 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
83 /// assert_eq!(s.idx_advise()[0].name, b"ev.age");
84 /// # Ok::<(), kevy_embedded::KevyError>(())
85 /// ```
86 pub name: Vec<u8>,
87 /// The declaration command that would have served them.
88 ///
89 /// ```
90 /// # use kevy_embedded::{Config, IndexValType, IndexValue, Store, TableSpec};
91 /// # let s = Store::open(Config::default())?;
92 /// # let mut t = TableSpec::default();
93 /// # (t.name, t.prefix, t.pk) = (b"ev".to_vec(), b"ev:".to_vec(), b"id".to_vec());
94 /// # t.columns = vec![(b"id".to_vec(), IndexValType::Str), (b"age".to_vec(), IndexValType::I64)];
95 /// # s.table_declare(t)?;
96 /// # let (lo, hi) = (IndexValue::I64(0), IndexValue::I64(99));
97 /// // the table has an `age` column but no index over it
98 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
99 /// assert!(s.idx_count(b"ev.age", &lo, &hi).is_err());
100 /// let advice = &s.idx_advise()[0].advice;
101 /// assert!(advice.contains("INDEX age range"), "{advice}");
102 /// # Ok::<(), kevy_embedded::KevyError>(())
103 /// ```
104 pub advice: String,
105}
106
107impl Store {
108 /// Observed refusal families, most-refused first, each rendered
109 /// as the declaration that would have served it. Families the
110 /// table catalog cannot ground (unknown table / column) are
111 /// withheld — those queries were malformed, not under-declared.
112 /// The log clears on every catalog mutation.
113 pub fn idx_advise(&self) -> Vec<IdxAdvice> {
114 let entries: Vec<kevy_index::AdviseEntry> = {
115 let g = self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner);
116 g.entries().into_iter().cloned().collect()
117 };
118 let mut rows: Vec<IdxAdvice> = {
119 let cat = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
120 entries
121 .iter()
122 .filter_map(|e| {
123 e.advice(&cat).map(|advice| IdxAdvice {
124 count: e.count,
125 name: e.name.clone(),
126 advice,
127 })
128 })
129 .collect()
130 };
131 rows.extend(self.reclaim_rows());
132 rows
133 }
134
135 /// The window-narrowing face, then the reclaim face: a windowed
136 /// path whose every observed probe left more than a bucket of
137 /// margin advises a smaller SPAN; a declared path no query has
138 /// ever hit advises its own drop, with its age. Dropping and
139 /// narrowing both stay human acts.
140 fn reclaim_rows(&self) -> Vec<IdxAdvice> {
141 let now_s = (kevy_store::now_unix_ms() / 1000) as i64;
142 let mut narrow: Vec<IdxAdvice> = Vec::new();
143 let mut unused: Vec<IdxAdvice> = Vec::new();
144 let tables = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
145 for (name, c) in self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).iter() {
146 let margin = c.min_margin();
147 if let Some(dot) = name.iter().position(|&b| b == b'.')
148 && let Some(spec) = tables.get(&name[..dot])
149 && let Some(advice) = spec.narrow_advice(margin)
150 {
151 narrow.push(IdxAdvice { count: 0, name: name.clone(), advice });
152 }
153 let (hits, _, declared) = c.read();
154 if hits == 0 {
155 let n = String::from_utf8_lossy(name).into_owned();
156 let age = (now_s - declared).max(0);
157 unused.push(IdxAdvice {
158 count: 0,
159 name: name.clone(),
160 advice: format!("IDX.DROP {n} (never hit in the {age}s since declare)"),
161 });
162 }
163 }
164 narrow.sort_by(|a, b| a.name.cmp(&b.name));
165 unused.sort_by(|a, b| a.name.cmp(&b.name));
166 narrow.extend(unused);
167 narrow
168 }
169
170 /// Record one refused declaration family; past the threshold, on
171 /// an opted-in table, the declare-period action runs right here —
172 /// the refusal path is cold, and the query that pushed the count
173 /// over still gets its error.
174 pub(crate) fn observe_refused(&self, name: &[u8], shape: AdviseShape) {
175 let count = self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner).observe(
176 name,
177 shape.clone(),
178 &[],
179 );
180 if count >= kevy_index::AUTODECLARE_AFTER {
181 // a replica takes its catalog from its primary and declares
182 // nothing; a record that fails is the log's failure, which
183 // the next write meets too
184 drop(self.catalog_change(|| {
185 self.auto_declare(name, shape, count);
186 Ok(())
187 }));
188 }
189 }
190
191 /// The embedded declare-period action — same shared rule
192 /// ([`kevy_index::TableSpec::apply_auto`]), same delta discipline as the
193 /// server: a whole new path registers, a changed one (auto
194 /// VALUES) rebuilds. Failures leave everything unchanged.
195 fn auto_declare(&self, name: &[u8], shape: AdviseShape, count: u64) {
196 let Some(dot) = name.iter().position(|&b| b == b'.') else { return };
197 let mut spec = {
198 let g = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
199 match g.get(&name[..dot]) {
200 Some(s) if s.autodeclare != 0 => s.clone(),
201 _ => return,
202 }
203 };
204 let entry = kevy_index::AdviseEntry::new(name, shape, count);
205 let Some(ledger) = spec.apply_auto(&entry) else { return };
206 let Ok(compiled) = spec.compile() else { return };
207 let path = match ledger.iter().position(|&b| b == b'#') {
208 Some(p) => ledger[..p].to_vec(),
209 None => ledger,
210 };
211 let Some(ispec) = compiled.into_iter().find(|s| s.name() == path) else { return };
212 // Registry first, catalog second: once the name is free the
213 // only register refusal is the tier floor, probed up front so
214 // a refusal leaves the old path standing.
215 #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
216 if crate::ops_index_sync::tier_floor_check(&self.shards).is_err() {
217 return;
218 }
219 self.drop_index(&path);
220 if self.register_spec(ispec).is_err() {
221 return;
222 }
223 let mut g = self.tables.catalog.write().unwrap_or_else(PoisonError::into_inner);
224 g.drop_table(&spec.name);
225 // the declaration the catalog admitted, with only its auto part
226 // grown
227 drop(g.create(spec));
228 }
229
230 /// [`Self::observe_refused`] when `r` is a no-such-index refusal
231 /// (the scalar walk's wording or the text face's) — the wrap for
232 /// entry points whose refusal is born deep in the segment walk.
233 pub(crate) fn observe_noindex<T>(&self, name: &[u8], shape: AdviseShape, r: &KevyResult<T>) {
234 if let Err(KevyError::NotFound(m)) = r
235 && (m == "no such index" || m == "no such text index")
236 {
237 self.observe_refused(name, shape);
238 }
239 }
240
241 /// Forget every observed refusal (a catalog just changed).
242 pub(crate) fn advise_clear(&self) {
243 self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner).clear();
244 }
245
246 /// [`Self::idx_match_with`], additionally counting the values of
247 /// the `FACET` fields over the whole match set — not just the
248 /// page, which is why the counts cannot be derived from the hits.
249 /// Lives here so the observation wrap sits with its family.
250 #[cfg(feature = "text")]
251 pub fn idx_match_faceted(
252 &self,
253 name: &[u8],
254 query: &[u8],
255 limit: usize,
256 opts: crate::MatchOpts<'_>,
257 ) -> KevyResult<crate::MatchPage> {
258 let r = self.match_faceted_run(name, query, limit, opts);
259 self.observe_noindex(name, AdviseShape::Match, &r);
260 if r.is_ok() {
261 self.observe_hit(name);
262 }
263 r
264 }
265
266 /// Count one served query against a declared path — the
267 /// observation's dual, called by the same entry-point wraps.
268 pub(crate) fn observe_hit(&self, name: &[u8]) {
269 let cell =
270 self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).get(name).cloned();
271 if let Some(c) = cell {
272 c.hit((kevy_store::now_unix_ms() / 1000) as i64);
273 }
274 }
275
276 /// The usage cell for a declared path (None = not declared).
277 /// Only the probe wraps read it, and the window tier compiles
278 /// out on wasm.
279 #[cfg(not(target_arch = "wasm32"))]
280 pub(crate) fn usage_cell(&self, name: &[u8]) -> Option<std::sync::Arc<kevy_index::UsageCell>> {
281 self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).get(name).cloned()
282 }
283
284 /// Is `name` a path the auto loop declared (any table's ledger)?
285 pub(crate) fn is_auto_path(&self, name: &[u8]) -> bool {
286 let g = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
287 g.iter().any(|s| s.auto_added.iter().any(|e| e == name))
288 }
289
290 /// `(hits, last_hit_s, declared_s)` for a declared path.
291 #[must_use]
292 pub fn idx_usage(&self, name: &[u8]) -> Option<(u64, i64, i64)> {
293 self.indexes
294 .usage
295 .read()
296 .unwrap_or_else(PoisonError::into_inner)
297 .get(name)
298 .map(|c| c.read())
299 }
300
301 /// Re-key the usage table to the current catalog, keeping
302 /// same-name cells — counters survive unrelated installs,
303 /// dropped paths drop, new paths date from now.
304 pub(crate) fn usage_rekey(&self) {
305 let names: Vec<Vec<u8>> = {
306 let g = self.indexes.catalog.read().unwrap_or_else(PoisonError::into_inner);
307 g.1.iter().map(|(s, _)| s.name().to_vec()).collect()
308 };
309 let now_s = (kevy_store::now_unix_ms() / 1000) as i64;
310 let mut g = self.indexes.usage.write().unwrap_or_else(PoisonError::into_inner);
311 let old = std::mem::take(&mut *g);
312 for n in names {
313 let cell = old
314 .get(&n)
315 .cloned()
316 .unwrap_or_else(|| std::sync::Arc::new(kevy_index::UsageCell::declared_at(now_s)));
317 g.insert(n, cell);
318 }
319 }
320}