Skip to main content

mentat/
auto_index.rs

1// Copyright 2026 the Mentat authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not use
4// this file except in compliance with the License. You may obtain a copy of the
5// License at http://www.apache.org/licenses/LICENSE-2.0
6// Unless required by applicable law or agreed to in writing, software distributed
7// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
8// CONDITIONS OF ANY KIND, either express or implied. See the License for the
9// specific language governing permissions and limitations under the License.
10
11//! Automatic index management for the embedded store.
12//!
13//! `datoms` always has EAVT and AEVT indexes. The schema's `:db/index` and
14//! `:db/unique` add partial AVET indexes (`WHERE index_avet IS NOT 0`), but the
15//! query SQL never states that predicate, so SQLite can't use them: a
16//! value-filtered pattern like `[?u :user/email "x"]` would scan every
17//! `:user/email` datom through AEVT. So every `:db/index` / `:db/unique`
18//! attribute also gets a *schema* value index (below), created and dropped
19//! with the flag by `mentat_db::db::sync_schema_indexes`, in every mode.
20//!
21//! In [`AutoIndex::Adaptive`] mode the store also counts, per attribute, the queries
22//! whose pattern pins that attribute's value (a constant or a bound `:in`
23//! scalar). When an attribute reaches `min_uses` queries within one tuning
24//! period, mentat creates
25//! `idx_auto_avet_<a> ON datoms (a, v, e) WHERE a = <a>`. The planner uses
26//! that without statistics, because the query already says `a = <a>`. There's
27//! no `value_type_tag` column: an attribute has one value type, and the SQL
28//! doesn't pin the tag when the type is known, so a tag column between `a` and
29//! `v` would stop the index serving `v = ?`.
30//! Indexes mentat creates are listed in `mentat_managed_indexes`, with `kind`
31//! `schema` or `adaptive`. An adaptive index is
32//! dropped when a whole period passes without a use *and* its last use is at
33//! least `idle` old; a schema index only when its attribute loses the flag. That gap between "`min_uses` in a period" and "no use for
34//! `idle`" is the hysteresis. Nothing outside that table is ever dropped.
35//! An index whose average value matches over 5% of the attribute's datoms is
36//! dropped straight after it's built: SQLite would probe it and lose to the
37//! AEVT scan (the scale suite's `:issue/state`, 5 values).
38
39use std::collections::{BTreeMap, BTreeSet};
40use std::fmt;
41use std::time::{Duration, SystemTime, UNIX_EPOCH};
42
43use core_traits::Entid;
44use mentat_core::{HasSchema, Keyword, Schema};
45use mentat_db::db;
46use public_traits::errors::Result;
47
48/// How mentat manages secondary indexes on `datoms`.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum AutoIndex {
51    /// No adaptive indexes, and [`Store::tune_indexes`] does nothing. Schema
52    /// value indexes still follow `:db/index` / `:db/unique` (they're part of
53    /// the schema, like SQLite's own indexes).
54    ///
55    /// [`Store::tune_indexes`]: crate::Store::tune_indexes
56    Off,
57    /// Only the schema's indexes: a value index per `:db/index` /
58    /// `:db/unique` attribute. [`Store::tune_indexes`] drops any adaptive
59    /// index mentat created earlier. This is the default.
60    ///
61    /// [`Store::tune_indexes`]: crate::Store::tune_indexes
62    #[default]
63    Schema,
64    /// Schema indexes, plus per-attribute value indexes created and dropped
65    /// from the query workload. Tuning runs automatically every
66    /// [`PERIOD`] queries, and when an attribute reaches `min_uses`.
67    Adaptive,
68}
69
70/// One index change made (or, in a dry run, proposed) by `tune_indexes`.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum IndexAction {
73    Create {
74        index: String,
75        attribute: Entid,
76        ident: Option<Keyword>,
77        /// Value-filtered queries on the attribute in this period.
78        uses: u32,
79    },
80    Drop {
81        index: String,
82        attribute: Entid,
83        ident: Option<Keyword>,
84    },
85}
86
87impl fmt::Display for IndexAction {
88    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
89        let who = |a: &Entid, i: &Option<Keyword>| match i {
90            Some(k) => format!("{k}"),
91            None => a.to_string(),
92        };
93        match self {
94            IndexAction::Create {
95                index,
96                attribute,
97                ident,
98                uses,
99            } => write!(
100                f,
101                "create {index} on {} ({uses} value-filtered queries)",
102                who(attribute, ident)
103            ),
104            IndexAction::Drop {
105                index,
106                attribute,
107                ident,
108            } => write!(f, "drop {index} on {} (idle)", who(attribute, ident)),
109        }
110    }
111}
112
113/// Queries between automatic tuning runs in `Adaptive` mode.
114pub const PERIOD: u32 = 1000;
115/// User attributes only: bootstrap attributes are few and cached.
116const USER0: Entid = 0x10000;
117
118#[derive(Debug)]
119pub(crate) struct Advisor {
120    pub(crate) mode: AutoIndex,
121    pub(crate) min_uses: u32,
122    pub(crate) idle: Duration,
123    uses: BTreeMap<Entid, u32>,
124    queries: u32,
125    /// Attributes whose index turned out unselective; not retried by this Store.
126    rejected: BTreeSet<Entid>,
127    /// Attributes with a managed index, as of the last tuning run.
128    indexed: BTreeSet<Entid>,
129}
130
131impl Default for Advisor {
132    fn default() -> Advisor {
133        let mode = match std::env::var("MENTAT_AUTO_INDEX").as_deref() {
134            Ok("off") => AutoIndex::Off,
135            Ok("adaptive") => AutoIndex::Adaptive,
136            _ => AutoIndex::default(),
137        };
138        Advisor {
139            mode,
140            min_uses: 16,
141            idle: Duration::from_secs(24 * 3600),
142            uses: BTreeMap::new(),
143            queries: 0,
144            rejected: BTreeSet::new(),
145            indexed: BTreeSet::new(),
146        }
147    }
148}
149
150fn now() -> i64 {
151    SystemTime::now()
152        .duration_since(UNIX_EPOCH)
153        .map(|d| d.as_secs() as i64)
154        .unwrap_or(0)
155}
156
157/// `(name, attribute, last_used, is_schema)` for every index mentat created.
158fn registry(conn: &rusqlite::Connection) -> Result<Vec<(String, Entid, i64, bool)>> {
159    if !db::has_table(conn, "mentat_managed_indexes")? {
160        return Ok(vec![]);
161    }
162    let mut stmt = conn.prepare("SELECT name, a, last_used, kind FROM mentat_managed_indexes")?;
163    let rows: Vec<(String, Entid, i64, bool)> = stmt
164        .query_map([], |r| {
165            Ok((
166                r.get(0)?,
167                r.get(1)?,
168                r.get(2)?,
169                r.get::<_, String>(3)? == "schema",
170            ))
171        })?
172        .collect::<rusqlite::Result<Vec<_>>>()?;
173    // Only ever act on names mentat generates, whatever the table says.
174    Ok(rows
175        .into_iter()
176        .filter(|(n, a, _, _)| *n == db::value_index_name(*a))
177        .collect())
178}
179
180/// From the index's fresh `sqlite_stat1` row (`nrow rows/a rows/(a,v) 1`): is
181/// the average value rare enough that probing the index beats the AEVT scan?
182// ponytail: fixed 5% cut from the scale suite (a 20%-per-value enum got
183// slower, a unique email 4x faster); per-query costing if it misjudges.
184fn selective(conn: &rusqlite::Connection, index: &str) -> Result<bool> {
185    let stat: Option<String> = conn
186        .query_row(
187            "SELECT stat FROM sqlite_stat1 WHERE idx = ?",
188            [index],
189            |r| r.get(0),
190        )
191        .ok();
192    let n: Vec<u64> = stat
193        .unwrap_or_default()
194        .split_whitespace()
195        .filter_map(|x| x.parse().ok())
196        .collect();
197    Ok(match n[..] {
198        [rows, _, per_value, ..] => per_value * 20 <= rows.max(1),
199        _ => true, // No statistics (e.g. an empty attribute): keep it.
200    })
201}
202
203impl Advisor {
204    /// Count one query's value-filtered attributes. True when a tuning run is
205    /// due: every `PERIOD` queries, or as soon as an attribute without an index
206    /// reaches `min_uses`.
207    pub(crate) fn record(&mut self, attrs: &BTreeSet<Entid>) -> bool {
208        self.queries += 1;
209        let mut due = self.queries >= PERIOD;
210        for a in attrs {
211            let n = self.uses.entry(*a).or_insert(0);
212            *n += 1;
213            due |= *n == self.min_uses && !self.indexed.contains(a) && !self.rejected.contains(a);
214        }
215        due
216    }
217
218    pub(crate) fn tune(
219        &mut self,
220        conn: &rusqlite::Connection,
221        schema: &Schema,
222        dry_run: bool,
223    ) -> Result<Vec<IndexAction>> {
224        if self.mode == AutoIndex::Off {
225            return Ok(vec![]);
226        }
227        let tx = conn.unchecked_transaction()?;
228        if !dry_run {
229            // Existing stores, and anything a transaction missed.
230            db::sync_schema_indexes(&tx, schema)?;
231            db::refresh_value_index_stats(&tx)?;
232        }
233        let managed = registry(&tx)?;
234        let ident = |a: Entid| schema.get_ident(a).cloned();
235        let now = now();
236        let mut actions = vec![];
237        let have: BTreeSet<Entid> = managed.iter().map(|(_, a, _, _)| *a).collect();
238        if self.mode == AutoIndex::Adaptive {
239            for (&a, &uses) in &self.uses {
240                let eligible = a >= USER0
241                    && schema
242                        .attribute_for_entid(a)
243                        .is_some_and(|attr| !attr.fulltext);
244                if uses >= self.min_uses
245                    && eligible
246                    && !have.contains(&a)
247                    && !self.rejected.contains(&a)
248                {
249                    actions.push(IndexAction::Create {
250                        index: db::value_index_name(a),
251                        attribute: a,
252                        ident: ident(a),
253                        uses,
254                    });
255                }
256            }
257        }
258        for (index, a, last_used, is_schema) in &managed {
259            if *is_schema {
260                continue; // Follows the schema flag, not the workload.
261            }
262            let used = self.uses.get(a).copied().unwrap_or(0) > 0;
263            let idle = now.saturating_sub(*last_used) as u64 >= self.idle.as_secs();
264            if self.mode == AutoIndex::Schema || (!used && idle) {
265                actions.push(IndexAction::Drop {
266                    index: index.clone(),
267                    attribute: *a,
268                    ident: ident(*a),
269                });
270            }
271        }
272        if dry_run {
273            return Ok(actions);
274        }
275
276        for (_, a, _, _) in &managed {
277            if self.uses.get(a).copied().unwrap_or(0) > 0 {
278                tx.execute(
279                    "UPDATE mentat_managed_indexes SET last_used = ? WHERE a = ?",
280                    rusqlite::params![now, a],
281                )?;
282            }
283        }
284        let mut rejected = BTreeSet::new();
285        for action in &actions {
286            match action {
287                IndexAction::Create {
288                    index, attribute, ..
289                } => {
290                    // Built and fully ANALYZEd (O(attribute), like the build):
291                    // with no stat1 row the planner may probe it last, after
292                    // joining everything else, and `selective` reads it.
293                    db::create_value_index(&tx, *attribute, "adaptive")?;
294                    if !selective(&tx, index)? {
295                        db::drop_value_index(&tx, *attribute)?;
296                        rejected.insert(*attribute);
297                    }
298                }
299                IndexAction::Drop { attribute, .. } => {
300                    db::drop_value_index(&tx, *attribute)?;
301                }
302            }
303        }
304        tx.commit()?;
305        self.indexed = registry(conn)?.into_iter().map(|(_, a, _, _)| a).collect();
306        actions.retain(
307            |a| !matches!(a, IndexAction::Create { attribute, .. } if rejected.contains(attribute)),
308        );
309        self.rejected.extend(rejected);
310        // A new period.
311        self.uses.clear();
312        self.queries = 0;
313        Ok(actions)
314    }
315}