Rindle docs and package mapSkip to main content

rindle_replica/
analyze.rs

1//! `rindle analyze query` — the report the engine produces and the daemon serializes.
2//!
3//! `Engine::analyze_query` (`engine.rs`) runs a query **cold** on a throwaway,
4//! instrumented pipeline over a pinned read snapshot and fills in this report: the
5//! chosen plan (IVM flip decisions + the planned AST), the per-phase timing, and — per
6//! source — the rows it **emitted** into the pipeline (the `CountingSource` decorator)
7//! beside the SQLite scan **work** it cost (`ScanStats`, under the `analyze` feature).
8//! The headline is the per-source `scanned / emitted` amplification, which ties into the
9//! static `rindle-index-advice` advisory (`ANALYZE-QUERY-DESIGN.md` §3.5).
10//!
11//! The types are plain serde structs (no engine handles) so they cross the worker→
12//! coordinator channel (`Send`) and serialize straight to the `/analyze` JSON reply.
13//! Field names render **camelCase** to match the design's wire shape and the CLI/JS
14//! consumers.
15
16use std::collections::BTreeMap;
17
18use rindle::{Ast, Condition};
19use serde::Serialize;
20
21/// The full analyze report for one query — the `/analyze` response body.
22#[derive(Debug, Clone, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct AnalyzeReport {
25    pub plan: AnalyzePlan,
26    pub timing: AnalyzeTiming,
27    pub sources: Vec<AnalyzeSource>,
28    pub totals: AnalyzeTotals,
29}
30
31/// The chosen plan — both layers (`ANALYZE-QUERY-DESIGN.md` §3.1).
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct AnalyzePlan {
35    /// Whether the daemon's planner gate fired (`plan_queries && has_flippable_exists`).
36    /// `false` ⇒ the query lowered unplanned (no flippable EXISTS, or planning disabled),
37    /// and `joins` is empty.
38    pub planning: bool,
39    /// One entry per flippable EXISTS join, carrying the planner's flip decision. This is
40    /// the **current-stats** plan (re-planned now), which may differ from a long-lived
41    /// materialization's frozen plan — see the doc's frozen-plan caveat (§3.1).
42    pub joins: Vec<AnalyzeJoin>,
43    /// The planned AST itself (carries the `flip` annotations), for a `--json` consumer
44    /// that wants the exact lowered shape.
45    pub ast: serde_json::Value,
46}
47
48/// One flippable EXISTS join's routing decision.
49#[derive(Debug, Clone, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct AnalyzeJoin {
52    /// The child (subquery) table the EXISTS correlates to.
53    pub exists: String,
54    /// `true` ⇒ child-driven (flipped: hydrate from the distinct child keys); `false` ⇒
55    /// parent-driven (scan parents).
56    pub flip: bool,
57    /// Human label for `flip` (`"child-driven"` / `"parent-driven"`).
58    pub reason: &'static str,
59}
60
61/// Per-phase wall-clock of the throwaway run (`ANALYZE-QUERY-DESIGN.md` §3.2). `hydrate`
62/// dominates — it drives every source fetch and therefore all the scan/emit counting.
63#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AnalyzeTiming {
66    pub plan_ms: f64,
67    pub build_ms: f64,
68    pub hydrate_ms: f64,
69}
70
71/// The numbers for one source leaf — the row the CLI renders per table.
72#[derive(Debug, Clone, Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct AnalyzeSource {
75    /// Source table name.
76    pub table: String,
77    /// The throwaway pipeline's source `NodeId` index (correlation only; the graph is gone).
78    pub node: u32,
79    /// Rows this source **vended into the pipeline** — the `CountingSource` tally. Backend-
80    /// agnostic (memory or SQLite), the one number available even without the feature.
81    pub emitted: u64,
82    /// SQLite scan **work** proxy (`vm_steps`) for this leaf — `None` when the `analyze`
83    /// feature (⇒ `scan-stats`) is off, or on a memory source (no SQLite). This is a v1
84    /// work-proxy, **not** a row count; the true rows-visited (`NVISIT`) row-ratio is v2.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub scanned: Option<u64>,
87    /// Rows an **un-indexed full scan** stepped over (`fullscan_steps`) — the direct
88    /// "a missing index made SQLite scan this table" signal. `None` without the feature.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub fullscan_steps: Option<u64>,
91    /// `scanned / emitted` (work per emitted row). `None` when `scanned` is unavailable or
92    /// `emitted == 0`. The lead diagnostic: a good index keeps it near-flat; a missing one
93    /// makes it explode.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub ratio: Option<f64>,
96    /// A concrete `CREATE INDEX …` line, present when this leaf full-scanned and the static
97    /// advisory (`derive_query`) names an index for it (§3.5).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub advisory: Option<String>,
100    /// The SQLite access path chosen for this leaf's `SELECT` — the planner's `EXPLAIN`
101    /// text (`"SCAN comments"` vs `"SEARCH comments USING INDEX …"`). `None` without the
102    /// `analyze` feature. The plain-language companion to the ratio: a full `SCAN` on an
103    /// amplified leaf is the smoking gun the number flags.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub sqlite_plan: Option<String>,
106    /// What `scanned` measures, for honest rendering: `"vm_steps (work proxy)"` when the
107    /// feature is on, else `"unavailable"`.
108    pub scanned_kind: &'static str,
109}
110
111/// Query-wide totals. `outputRows` (final deduped IVM output) vs. `emitted` (rows sources
112/// pushed in) is a third useful gap — a big spread means the pipeline filters/joins a lot
113/// above the sources.
114#[derive(Debug, Clone, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub struct AnalyzeTotals {
117    /// Sum of per-source `scanned` (work proxy); `None` when the feature is off.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub scanned: Option<u64>,
120    /// Sum of per-source `emitted`.
121    pub emitted: u64,
122    /// Final materialized top-level row count (the fresh-query result size).
123    pub output_rows: usize,
124}
125
126/// Walk an AST's filter tree (and its `related` subqueries) collecting every flippable
127/// EXISTS join's planner decision. Called on the **planned** AST, so `flip` is `Some`.
128pub(crate) fn collect_joins(ast: &Ast) -> Vec<AnalyzeJoin> {
129    let mut out = Vec::new();
130    if let Some(cond) = &ast.r#where {
131        walk_cond(cond, &mut out);
132    }
133    for rel in &ast.related {
134        // Nested materialized relationships can themselves carry a planned EXISTS.
135        out.extend(collect_joins(&rel.subquery));
136    }
137    out
138}
139
140fn walk_cond(cond: &Condition, out: &mut Vec<AnalyzeJoin>) {
141    match cond {
142        Condition::And { conditions } | Condition::Or { conditions } => {
143            for c in conditions {
144                walk_cond(c, out);
145            }
146        }
147        Condition::CorrelatedSubquery(csq) => {
148            if let Some(flip) = csq.flip {
149                out.push(AnalyzeJoin {
150                    exists: csq.related.subquery.table.to_string(),
151                    flip,
152                    reason: if flip {
153                        "child-driven"
154                    } else {
155                        "parent-driven"
156                    },
157                });
158            }
159            // A flippable EXISTS can nest another inside its subquery's `where`.
160            if let Some(inner) = &csq.related.subquery.r#where {
161                walk_cond(inner, out);
162            }
163        }
164        Condition::Simple(_) => {}
165    }
166}
167
168/// Derive the concrete `CREATE INDEX` advisory line per table for a query, keyed by table.
169/// The same SQLite-free derivation the `indices suggest` CLI uses (`derive_query` +
170/// `create_index_sql`); analyze looks up the leaf it measured as high-amplification and
171/// prints the line the static advisory would have suggested (§3.5). `pks` maps each table
172/// to its primary-key column **names**. Takes the FIRST suggestion per table (the
173/// correlation / join-key index is derived first, which is the one a scanned≫emitted leaf
174/// wants).
175pub(crate) fn advisories(ast: &Ast, pks: &BTreeMap<&str, &[String]>) -> BTreeMap<String, String> {
176    let mut sugg = Vec::new();
177    rindle_index_advice::derive_query("analyze", ast, pks, &mut sugg);
178    let mut out: BTreeMap<String, String> = BTreeMap::new();
179    for s in &sugg {
180        out.entry(s.table.clone())
181            .or_insert_with(|| rindle_index_advice::create_index_sql(&s.table, &s.cols));
182    }
183    out
184}