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