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}