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, advice_of};
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) = kevy_index::window_value_of(lower, 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#[derive(Debug, Clone)]
45pub struct IdxAdvice {
46    /// Refusals observed for this family.
47    pub count: u64,
48    /// The access-path name the queries asked for.
49    pub name: Vec<u8>,
50    /// The declaration command that would have served them.
51    pub advice: String,
52}
53
54impl Store {
55    /// Observed refusal families, most-refused first, each rendered
56    /// as the declaration that would have served it. Families the
57    /// table catalog cannot ground (unknown table / column) are
58    /// withheld — those queries were malformed, not under-declared.
59    /// The log clears on every catalog mutation.
60    pub fn idx_advise(&self) -> Vec<IdxAdvice> {
61        let entries: Vec<kevy_index::AdviseEntry> = {
62            let g = self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner);
63            g.entries().into_iter().cloned().collect()
64        };
65        let mut rows: Vec<IdxAdvice> = {
66            let cat = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
67            entries
68                .iter()
69                .filter_map(|e| {
70                    advice_of(e, &cat).map(|advice| IdxAdvice {
71                        count: e.count,
72                        name: e.name.clone(),
73                        advice,
74                    })
75                })
76                .collect()
77        };
78        rows.extend(self.reclaim_rows());
79        rows
80    }
81
82    /// The window-narrowing face, then the reclaim face: a windowed
83    /// path whose every observed probe left more than a bucket of
84    /// margin advises a smaller SPAN; a declared path no query has
85    /// ever hit advises its own drop, with its age. Dropping and
86    /// narrowing both stay human acts.
87    fn reclaim_rows(&self) -> Vec<IdxAdvice> {
88        let now_s = (kevy_store::now_unix_ms() / 1000) as i64;
89        let mut narrow: Vec<IdxAdvice> = Vec::new();
90        let mut unused: Vec<IdxAdvice> = Vec::new();
91        let tables = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
92        for (name, c) in self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).iter() {
93            let margin = c.min_margin.load(std::sync::atomic::Ordering::Relaxed);
94            if let Some(dot) = name.iter().position(|&b| b == b'.')
95                && let Some(spec) = tables.get(&name[..dot])
96                && let Some(advice) = kevy_index::narrow_advice(spec, margin)
97            {
98                narrow.push(IdxAdvice { count: 0, name: name.clone(), advice });
99            }
100            let (hits, _, declared) = c.read();
101            if hits == 0 {
102                let n = String::from_utf8_lossy(name).into_owned();
103                let age = (now_s - declared).max(0);
104                unused.push(IdxAdvice {
105                    count: 0,
106                    name: name.clone(),
107                    advice: format!("IDX.DROP {n}  (never hit in the {age}s since declare)"),
108                });
109            }
110        }
111        narrow.sort_by(|a, b| a.name.cmp(&b.name));
112        unused.sort_by(|a, b| a.name.cmp(&b.name));
113        narrow.extend(unused);
114        narrow
115    }
116
117    /// Record one refused declaration family; past the threshold, on
118    /// an opted-in table, the declare-period action runs right here —
119    /// the refusal path is cold, and the query that pushed the count
120    /// over still gets its error.
121    pub(crate) fn observe_refused(&self, name: &[u8], shape: AdviseShape) {
122        let count = self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner).observe(
123            name,
124            shape.clone(),
125            &[],
126        );
127        if count >= kevy_index::AUTODECLARE_AFTER {
128            self.auto_declare(name, shape, count);
129        }
130    }
131
132    /// The embedded declare-period action — same shared rule
133    /// ([`kevy_index::apply_auto`]), same delta discipline as the
134    /// server: a whole new path registers, a changed one (auto
135    /// VALUES) rebuilds. Failures leave everything unchanged.
136    fn auto_declare(&self, name: &[u8], shape: AdviseShape, count: u64) {
137        let Some(dot) = name.iter().position(|&b| b == b'.') else { return };
138        let mut spec = {
139            let g = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
140            match g.get(&name[..dot]) {
141                Some(s) if s.autodeclare != 0 => s.clone(),
142                _ => return,
143            }
144        };
145        let entry =
146            kevy_index::AdviseEntry { name: name.to_vec(), shape, count, sample: Vec::new() };
147        let Some(ledger) = kevy_index::apply_auto(&mut spec, &entry) else { return };
148        let Ok(compiled) = kevy_index::compile_table(&spec) else { return };
149        let path = match ledger.iter().position(|&b| b == b'#') {
150            Some(p) => ledger[..p].to_vec(),
151            None => ledger,
152        };
153        let Some(ispec) = compiled.into_iter().find(|s| s.name == path) else { return };
154        // Registry first, catalog second: once the name is free the
155        // only register refusal is the tier floor, probed up front so
156        // a refusal leaves the old path standing.
157        #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
158        if crate::ops_index_sync::tier_floor_check(&self.shards).is_err() {
159            return;
160        }
161        self.idx_drop(&path);
162        if self.register_spec(ispec).is_err() {
163            return;
164        }
165        {
166            let mut g = self.tables.catalog.write().unwrap_or_else(PoisonError::into_inner);
167            g.drop_table(&spec.name);
168            if g.create(spec).is_err() {
169                return;
170            }
171        }
172        self.persist_table_sidecar();
173    }
174
175    /// [`Self::observe_refused`] when `r` is a no-such-index refusal
176    /// (the scalar walk's wording or the text face's) — the wrap for
177    /// entry points whose refusal is born deep in the segment walk.
178    pub(crate) fn observe_noindex<T>(&self, name: &[u8], shape: AdviseShape, r: &KevyResult<T>) {
179        if let Err(KevyError::NotFound(m)) = r
180            && (m == "no such index" || m == "no such text index")
181        {
182            self.observe_refused(name, shape);
183        }
184    }
185
186    /// Forget every observed refusal (a catalog just changed).
187    pub(crate) fn advise_clear(&self) {
188        self.tables.advise.lock().unwrap_or_else(PoisonError::into_inner).clear();
189    }
190
191    /// [`Self::idx_match_with`], additionally counting the values of
192    /// the `FACET` fields over the whole match set — not just the
193    /// page, which is why the counts cannot be derived from the hits.
194    /// Lives here so the observation wrap sits with its family.
195    #[cfg(feature = "text")]
196    pub fn idx_match_faceted(
197        &self,
198        name: &[u8],
199        query: &[u8],
200        limit: usize,
201        opts: crate::MatchOpts<'_>,
202    ) -> KevyResult<crate::MatchPage> {
203        let r = self.match_faceted_run(name, query, limit, opts);
204        self.observe_noindex(name, AdviseShape::Match, &r);
205        if r.is_ok() {
206            self.observe_hit(name);
207        }
208        r
209    }
210
211    /// Count one served query against a declared path — the
212    /// observation's dual, called by the same entry-point wraps.
213    pub(crate) fn observe_hit(&self, name: &[u8]) {
214        let cell =
215            self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).get(name).cloned();
216        if let Some(c) = cell {
217            c.hit((kevy_store::now_unix_ms() / 1000) as i64);
218        }
219    }
220
221    /// The usage cell for a declared path (None = not declared).
222    /// Only the probe wraps read it, and the window tier compiles
223    /// out on wasm.
224    #[cfg(not(target_arch = "wasm32"))]
225    pub(crate) fn usage_cell(&self, name: &[u8]) -> Option<std::sync::Arc<kevy_index::UsageCell>> {
226        self.indexes.usage.read().unwrap_or_else(PoisonError::into_inner).get(name).cloned()
227    }
228
229    /// Is `name` a path the auto loop declared (any table's ledger)?
230    pub(crate) fn is_auto_path(&self, name: &[u8]) -> bool {
231        let g = self.tables.catalog.read().unwrap_or_else(PoisonError::into_inner);
232        g.iter().any(|s| s.auto_added.iter().any(|e| e == name))
233    }
234
235    /// `(hits, last_hit_s, declared_s)` for a declared path.
236    #[must_use]
237    pub fn idx_usage(&self, name: &[u8]) -> Option<(u64, i64, i64)> {
238        self.indexes
239            .usage
240            .read()
241            .unwrap_or_else(PoisonError::into_inner)
242            .get(name)
243            .map(|c| c.read())
244    }
245
246    /// Re-key the usage table to the current catalog, keeping
247    /// same-name cells — counters survive unrelated installs,
248    /// dropped paths drop, new paths date from now.
249    pub(crate) fn usage_rekey(&self) {
250        let names: Vec<Vec<u8>> = {
251            let g = self.indexes.catalog.read().unwrap_or_else(PoisonError::into_inner);
252            g.1.iter().map(|(s, _)| s.name.clone()).collect()
253        };
254        let now_s = (kevy_store::now_unix_ms() / 1000) as i64;
255        let mut g = self.indexes.usage.write().unwrap_or_else(PoisonError::into_inner);
256        let old = std::mem::take(&mut *g);
257        for n in names {
258            let cell = old
259                .get(&n)
260                .cloned()
261                .unwrap_or_else(|| std::sync::Arc::new(kevy_index::UsageCell::declared_at(now_s)));
262            g.insert(n, cell);
263        }
264    }
265}