Skip to main content

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}