Rindle docs and package mapSkip to main content

rindle_replica/
engine.rs

1//! The per-thread IVM unit: ONE `Graph`, one `TableSource` per registered table
2//! (shared by every query on this thread), and the registered queries (each a
3//! change-stream sink). One push of a captured batch fans out to every dependent
4//! query, so a commit needs just one derivation snapshot per thread — a plain read
5//! transaction; batch state rides in each source's in-memory [`BatchDelta`] overlay
6//! (design 306 batch-overlay derivation, the sole mode since S5 removed the
7//! historical write-then-abort replay).
8
9use std::cell::{Cell, RefCell};
10use std::collections::{BTreeMap, HashMap};
11use std::path::PathBuf;
12use std::rc::Rc;
13use std::sync::Arc;
14use std::time::Instant;
15
16use rindle::change::SourceChange;
17use rindle::family::{BindingSet, FamilyPipeline};
18use rindle::graph::{
19    CountingSource, EmitCounter, Graph, JoinPrecheckState, NodeId, PipelineManifest,
20};
21use rindle::storage::StorageFactory;
22use rindle::value::{values_identical, ColId, OwnedRow, Schema, Sort, SourceSchema};
23use rindle::{
24    build_family_pipeline, build_pipeline, has_scalar_subquery, resolve_scalars, view_schema, Ast,
25    CaughtChange, RindleError, ScalarCatalog, ScalarSource,
26};
27use rindle_cdc::Captured;
28use rindle_planner::{has_flippable_exists, plan_ast, CachingCostModel, ConnectionCostModel};
29use rindle_sqlite::{
30    BatchDelta, DatabaseStorage, DeltaBudget, GraphTableSourceExt, SqliteCostModel, TableSource,
31};
32use rindle_wire::family_key::Binding;
33use rusqlite::Connection;
34
35use crate::analyze::{
36    advisories, collect_joins, AnalyzePlan, AnalyzeReport, AnalyzeSource, AnalyzeTiming,
37    AnalyzeTotals,
38};
39use crate::parallel::open_journal;
40use crate::schema::TableSchema;
41use crate::JournalMode;
42use crate::QueryId;
43use crate::ReplicaError;
44
45/// Per-registered-query bookkeeping in the shared graph: the [`PipelineManifest`]
46/// (the exact node/storage slots its build created, for teardown) and the caller's
47/// opaque [`QueryId`] tag. Keyed by the query's change-sink `NodeId`. The engine does
48/// nothing with the tag (no de-dup / no refcount — that is the caller's concern); it is
49/// retained for correlation/observability and echoed back via the `Query` handle.
50/// What [`Engine::join_precheck_report`] returns: the join membership pre-check's bounds
51/// and per-query join states on one engine (design 311 §8 inspection).
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct JoinPrecheckReport {
54    /// The bounds in force on the engine's graph.
55    pub bounds: rindle::graph::JoinPrecheckBounds,
56    /// Distinct parent keys charged against the per-graph budget right now.
57    pub tracked_keys: usize,
58    /// Per registered query (sorted by id): the state of each join its pipeline owns, in
59    /// build order.
60    pub queries: Vec<(QueryId, Vec<JoinPrecheckState>)>,
61}
62
63/// What [`Engine::storage_report`] returns: how much operator scratch state one
64/// registered query's pipeline holds right now (design 310 impl plan D5's leak probe,
65/// lifted to the cluster boundary — see
66/// [`Cluster::__test_storage_entries`](crate::Cluster::__test_storage_entries)).
67#[derive(Clone, Debug, Default, PartialEq, Eq)]
68pub struct StorageReport {
69    /// `(key, value)` entries across every storage slot the pipeline owns.
70    pub entries: usize,
71    /// Every `(slot, key, value)`, when the caller asked for the dump; empty otherwise.
72    /// `slot` indexes the pipeline manifest's storage list (a build-order operator name).
73    pub dump: Vec<(usize, String, String)>,
74}
75
76struct QueryReg {
77    manifest: PipelineManifest,
78    /// The caller's opaque tag, echoed back via [`Engine::query_id_of`] when a
79    /// derived delta is emitted (the engine still never *interprets* it).
80    query_id: QueryId,
81    /// `Some` for a parameterized query family (design 310): the pipeline handle the
82    /// per-partition bind / unbind / hydrate entry points take.
83    family: Option<FamilyPipeline>,
84}
85
86/// Where stateful operators (`take` / `cap` / `reduce`) keep their scratch state.
87///
88/// The graph's [`StorageFactory`] decides this once, at construction, for every
89/// operator the engine's pipelines build. Both backends are equivalent in result —
90/// the SQLite-backed `OpStorage` agrees with `MemoryStorage` op-for-op — so the
91/// choice is purely an operational memory/throughput trade-off.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum OperatorStorage {
94    /// In-process `BTreeMap` scratch state (the default). Fastest, but the working
95    /// set is bounded by RAM.
96    #[default]
97    Memory,
98    /// Spill operator state to a private, on-disk temporary SQLite database (one per
99    /// engine, journal/sync off, exclusive locking — see
100    /// [`DatabaseStorage::new_temp_file`]). Lets operator state grow past RAM at the
101    /// cost of per-op SQLite calls; the scratch DB is deleted when the engine drops.
102    SqliteSpill,
103}
104
105/// A hosted table resolved to what a push needs: the shared source's `NodeId` plus the
106/// `Schema` whose primary key decides whether an `Edit` is pk-changing. The value half of
107/// [`Engine::sources`], named so the borrowed forms that thread it through the push path
108/// ([`Engine::push_resolved`] and its caller's memo) stay readable.
109type HostedSource = (NodeId, SourceSchema);
110
111/// The per-thread IVM unit (see the module docs). Internal to the `Db`/`Cluster`
112/// openers, and public via the crate's **embedded-engine seam** (design 308): a host
113/// that owns its own write path — the SQLite extension's commit hook — constructs one
114/// over a derivation connection, registers tables/queries, and drives
115/// [`apply_batch`](Engine::apply_batch) itself.
116pub struct Engine {
117    /// The single dataflow graph for this thread. `!Send`; never leaves the thread.
118    graph: Graph,
119    /// The connection the engine reads/derives against (its `TableSource`s hold it).
120    /// During a commit it holds the derivation snapshot transaction, closed by
121    /// [`rollback`](Engine::rollback).
122    worker: Rc<Connection>,
123    /// table name → (shared source `NodeId`, its `Schema`). The schema is what the
124    /// pipeline builder's `resolve` closure hands back for each referenced table.
125    sources: HashMap<String, HostedSource>,
126    /// table name → a build-time **read** handle ([`TableSource::fork`] over the same
127    /// worker connection + cached unique-index metadata) used by [`resolve_scalars`] to
128    /// fold a `scalar` correlated subquery before planning. Separate from the live IVM
129    /// source in the graph; it only does point reads / unique-key lookups.
130    scalar_sources: HashMap<String, TableSource>,
131    /// Every registered query, keyed by its change-sink `NodeId` (drained each commit).
132    /// The generational sink id is unique per registration, so a torn-down-then-rebuilt
133    /// query never collides with its former self.
134    queries: HashMap<NodeId, QueryReg>,
135    /// When set, [`register_query`](Self::register_query) runs the cost-based join-flip
136    /// planner over the AST before lowering (a [`SqliteCostModel`] on `worker`, wrapped
137    /// in [`CachingCostModel`]). Opt-in (default off) so the unplanned path stays the
138    /// default; the plan is frozen at registration (never re-planned). Planning is
139    /// result-preserving, so this only changes execution strategy, never the rows.
140    plan_queries: bool,
141    /// The operator-scratch-state backend this engine's graph was built with. Held so a
142    /// post-fault rebuild ([`fault_recover`](crate::parallel)) re-creates the graph with
143    /// the *same* backend rather than silently downgrading to memory.
144    operator_storage: OperatorStorage,
145    /// table name → the shared [`BatchDelta`] its source (and every fork) reads through.
146    /// Recorded so `open_snapshot`/`rollback` can drive the lifecycle (plan D1).
147    deltas: HashMap<String, Rc<BatchDelta>>,
148    /// The D4 derivation memory budget, shared by EVERY table's delta so one
149    /// transaction is bounded by this number and not by (tables × this number). Held on
150    /// the engine — and installed per `register_table` — so a shed/fault rebuild carries
151    /// the tuned ceiling instead of silently resetting to the default.
152    ///
153    /// One budget per engine means one per *thread*: a `Cluster`'s worker engines each
154    /// hold their own, so a process ceiling is `workers × max_bytes`.
155    delta_budget: Rc<DeltaBudget>,
156}
157
158impl Engine {
159    pub fn new(
160        worker: Rc<Connection>,
161        plan_queries: bool,
162        operator_storage: OperatorStorage,
163    ) -> Result<Engine, ReplicaError> {
164        let graph = build_graph(operator_storage)?;
165        Ok(Engine {
166            graph,
167            worker,
168            sources: HashMap::new(),
169            scalar_sources: HashMap::new(),
170            queries: HashMap::new(),
171            plan_queries,
172            operator_storage,
173            deltas: HashMap::new(),
174            delta_budget: Rc::new(DeltaBudget::default()),
175        })
176    }
177
178    pub fn has_source(&self, table: &str) -> bool {
179        self.sources.contains_key(table)
180    }
181
182    /// Build the shared `TableSource` for `table` on the worker connection, add it to
183    /// the graph, and remember its `Schema`. Idempotent.
184    pub fn register_table(&mut self, table: &str, ts: TableSchema) -> Result<(), ReplicaError> {
185        if self.sources.contains_key(table) {
186            return Ok(());
187        }
188        let columns = ts.columns;
189        let primary_key = ts.primary_key;
190        // The reported schema (relationships are derived query-locally by the builder,
191        // so a relationship-free schema is correct here): names + PK + primary sort.
192        let col_names: Vec<&str> = columns.iter().map(|c| &*c.name).collect();
193        let sort: Sort = primary_key.iter().map(|&c| (c, true)).collect();
194        let schema = SourceSchema::new(col_names, primary_key.clone(), sort)
195            .with_column_types(columns.iter().map(|c| c.ty).collect());
196
197        let source = TableSource::try_new(self.worker.clone(), table, columns, primary_key)?;
198        // Record the source's batch delta (design 306) BEFORE forking, so the scalar
199        // fork — and every later fork — shares it (plan D1: the delta models storage,
200        // which forks share). `open_snapshot`/`rollback` drive its lifecycle.
201        let delta = source.batch_delta();
202        // Every table charges the SAME budget — the ceiling is the transaction's, not
203        // each table's (design 306 D4).
204        delta.set_budget(Rc::clone(&self.delta_budget));
205        self.deltas.insert(table.to_string(), delta);
206        // A read-only fork (same connection + cached unique-index metadata) for
207        // build-time scalar resolution; the original drives the live IVM pipeline.
208        self.scalar_sources.insert(table.to_string(), source.fork());
209        let id = self.graph.add_table_source(source);
210        self.sources.insert(table.to_string(), (id, schema));
211        Ok(())
212    }
213
214    /// Lower `ast` into the shared graph, terminate it in a fresh change-stream sink,
215    /// and hydrate it. `query_id` is the caller's opaque tag (the engine stores it for
216    /// correlation but never interprets it — de-dup is the caller's concern). Returns
217    /// the sink's `NodeId` and the initial change set (the hydration `Add`s).
218    /// `BuildError` (unknown table/column, unsupported shape) is surfaced synchronously.
219    pub fn register_query(
220        &mut self,
221        query_id: QueryId,
222        ast: &Ast,
223    ) -> Result<(NodeId, Vec<CaughtChange>), ReplicaError> {
224        use std::panic::{catch_unwind, AssertUnwindSafe};
225
226        // Record the exact node/storage slots this build creates so the pipeline can be
227        // torn down precisely later (and so a *failed* build is cleaned up, not leaked).
228        self.graph.begin_recording();
229
230        // Build → wire the sink → hydrate under a panic boundary. The recording stays
231        // active THROUGH hydrate (which adds no nodes), so on ANY unwind the still-`Some`
232        // manifest captures everything built and we reclaim it below — no orphaned,
233        // wired, un-tear-down-able pipeline is left fanning pushes forever. Build/engine
234        // errors surface as `Err`; an internal-invariant *panic* (an engine bug, never
235        // valid input — malformed ASTs are `BuildError`) is caught here. Mirrors the push
236        // path's `source_push_isolated`; effective under `panic = "unwind"`
237        // (`release-server`), a passthrough under the client `abort` profile.
238        let outcome = {
239            let graph = &mut self.graph;
240            let sources = &self.sources;
241            let scalar_sources = &self.scalar_sources;
242            let worker = &self.worker;
243            let plan_queries = self.plan_queries;
244            catch_unwind(AssertUnwindSafe(move || {
245                let resolve = |t: &str| sources.get(t).map(|(id, sc)| (*id, sc.clone()));
246
247                // 1. Scalar-subquery resolution, BEFORE planning (design §8): fold a
248                //    statically-unique `scalar` EXISTS into a literal and delete the
249                //    join, so the planner enumerates over the already-simplified graph.
250                //    Reads the build-time snapshot through the fork catalog; the inlined
251                //    value is frozen for this pipeline (§3). Skips the clone when there
252                //    is nothing flagged.
253                let resolved;
254                let ast = if has_scalar_subquery(ast) {
255                    let catalog = ForkCatalog(scalar_sources);
256                    resolved = resolve_scalars(ast, &catalog)?;
257                    &resolved
258                } else {
259                    ast
260                };
261
262                // 2. Opt-in cost-based planning: annotate `flip` before lowering. A fresh
263                // `CachingCostModel` per registration (stats may drift between queries;
264                // the plan is frozen for this one). Runs INSIDE the panic boundary so a
265                // cost-model assert (e.g. scanstatus) surfaces as the internal-panic
266                // error rather than crashing the worker. Result-preserving by construction.
267                let planned;
268                let ast = if plan_queries && has_flippable_exists(ast) {
269                    let model: Rc<dyn ConnectionCostModel> =
270                        Rc::new(CachingCostModel::new(SqliteCostModel::new(worker.clone())));
271                    planned = plan_ast(ast, model);
272                    &planned
273                } else {
274                    // No flippable EXISTS (or planning off) ⇒ planning is a guaranteed
275                    // no-op; lower the AST directly (skips the plan-graph build + clone).
276                    ast
277                };
278                let top = build_pipeline(graph, ast, &resolve)?;
279                let sink = graph.add_change_sink(top);
280                graph.set_sink_edge(top, sink);
281                // Fallible cold drain: registering a `sum` over data whose set total is
282                // ALREADY outside i64 must fail typed here (§5.3 — a fresh SQLite
283                // `SELECT sum(…)` over this state errors too), not return Ok with a
284                // `NULL` aggregate while the armed overflow state fails every later
285                // write on the shared graph. The `Err` arm below tears the partial
286                // pipeline down, which also clears that state.
287                let initial = graph.try_hydrate_change_sink(sink)?;
288                Ok::<(NodeId, Vec<CaughtChange>), ReplicaError>((sink, initial))
289            }))
290        };
291
292        match outcome {
293            Ok(Ok((sink, initial))) => {
294                let manifest = self.graph.take_recording();
295                self.queries.insert(
296                    sink,
297                    QueryReg {
298                        manifest,
299                        query_id,
300                        family: None,
301                    },
302                );
303                Ok((sink, initial))
304            }
305            Ok(Err(e)) => {
306                // A surfaced build/engine error: tear out the partial pipeline.
307                let manifest = self.graph.take_recording();
308                self.graph.destroy_pipeline(&manifest);
309                Err(e)
310            }
311            Err(_panic) => {
312                // An internal-invariant panic mid build/hydrate: the recording still
313                // holds everything built before the unwind — reclaim it so no live,
314                // undrained orphan is left wired to the shared sources.
315                let manifest = self.graph.take_recording();
316                self.graph.destroy_pipeline(&manifest);
317                Err(ReplicaError::Rindle(RindleError::Storage(
318                    "internal panic during query registration".to_string(),
319                )))
320            }
321        }
322    }
323
324    /// Register a **parameterized query family** (design 310 §4 / impl plan §6.1) under
325    /// `query_id`: one pipeline over the family's stripped template whose `params`
326    /// columns are a partition dimension. Mirrors [`register_query`](Self::register_query)
327    /// — recording, the panic boundary, scalar-subquery resolution and planning on the
328    /// template — but builds through `build_family_pipeline` and does **not** hydrate:
329    /// partitions hydrate individually as they are bound
330    /// ([`bind_partition`](Self::bind_partition)). Returns the change-sink id.
331    pub fn register_family(
332        &mut self,
333        query_id: QueryId,
334        stripped: &Ast,
335        params: &[Box<str>],
336    ) -> Result<NodeId, ReplicaError> {
337        use std::panic::{catch_unwind, AssertUnwindSafe};
338
339        self.graph.begin_recording();
340        let outcome = {
341            let graph = &mut self.graph;
342            let sources = &self.sources;
343            let scalar_sources = &self.scalar_sources;
344            let worker = &self.worker;
345            let plan_queries = self.plan_queries;
346            catch_unwind(AssertUnwindSafe(move || {
347                let resolve = |t: &str| sources.get(t).map(|(id, sc)| (*id, sc.clone()));
348                let resolved;
349                let ast = if has_scalar_subquery(stripped) {
350                    let catalog = ForkCatalog(scalar_sources);
351                    resolved = resolve_scalars(stripped, &catalog)?;
352                    &resolved
353                } else {
354                    stripped
355                };
356                let planned;
357                let ast = if plan_queries && has_flippable_exists(ast) {
358                    let model: Rc<dyn ConnectionCostModel> =
359                        Rc::new(CachingCostModel::new(SqliteCostModel::new(worker.clone())));
360                    planned = plan_ast(ast, model);
361                    &planned
362                } else {
363                    ast
364                };
365                let fam = build_family_pipeline(graph, ast, params, BindingSet::new(), &resolve)?;
366                let sink = graph.add_change_sink(fam.top);
367                graph.set_sink_edge(fam.top, sink);
368                Ok::<(NodeId, FamilyPipeline), ReplicaError>((sink, fam))
369            }))
370        };
371        match outcome {
372            Ok(Ok((sink, fam))) => {
373                let manifest = self.graph.take_recording();
374                self.queries.insert(
375                    sink,
376                    QueryReg {
377                        manifest,
378                        query_id,
379                        family: Some(fam),
380                    },
381                );
382                Ok(sink)
383            }
384            Ok(Err(e)) => {
385                let manifest = self.graph.take_recording();
386                self.graph.destroy_pipeline(&manifest);
387                Err(e)
388            }
389            Err(_panic) => {
390                let manifest = self.graph.take_recording();
391                self.graph.destroy_pipeline(&manifest);
392                Err(ReplicaError::Rindle(RindleError::Storage(
393                    "internal panic during family registration".to_string(),
394                )))
395            }
396        }
397    }
398
399    /// Bind one partition of the family registered under `query_id` and hydrate only it
400    /// (design 310 §4.4): the partition's initial `Add` set. `Ok(None)` if `query_id` is not
401    /// a registered family; `Err` if the binding is already bound or the hydrate fails
402    /// (the binding is then rolled back).
403    pub fn bind_partition(
404        &self,
405        query_id: QueryId,
406        binding: &Binding,
407    ) -> Result<Option<Vec<CaughtChange>>, ReplicaError> {
408        let Some((sink, fam)) = self.family_of(query_id) else {
409            return Ok(None);
410        };
411        Ok(Some(self.graph.bind_family_partition(fam, sink, binding)?))
412    }
413
414    /// Unbind one partition of the family registered under `query_id` (design 310 §4.4,
415    /// impl plan D5). `Ok(false)` if `query_id` is not a registered family or the binding
416    /// was not bound.
417    pub fn unbind_partition(
418        &self,
419        query_id: QueryId,
420        binding: &Binding,
421    ) -> Result<bool, ReplicaError> {
422        let Some((sink, fam)) = self.family_of(query_id) else {
423            return Ok(false);
424        };
425        if !fam.bindings.contains(binding) {
426            return Ok(false);
427        }
428        self.graph.unbind_family_partition(fam, sink, binding)?;
429        Ok(true)
430    }
431
432    fn family_of(&self, query_id: QueryId) -> Option<(NodeId, &FamilyPipeline)> {
433        self.queries
434            .iter()
435            .find(|(_, reg)| reg.query_id == query_id)
436            .and_then(|(&sink, reg)| reg.family.as_ref().map(|f| (sink, f)))
437    }
438
439    /// Run `ast` **cold** on a throwaway, instrumented pipeline over a pinned read
440    /// snapshot and report where its time and rows go (`ANALYZE-QUERY-DESIGN.md`): the
441    /// chosen plan, the per-phase timing, and — per source — the rows it **emitted** into
442    /// the pipeline beside the SQLite scan **work** it cost.
443    ///
444    /// **Read-only and isolated.** It builds and drops a *fresh* [`Graph`] with *fresh*
445    /// sources (forks over the worker connection), so it never registers a materialization,
446    /// never dedups on the manager's `QueryKey`, never mutates, and never touches
447    /// `self.graph` / `self.sources` / the live materialization set. Every leaf fetch runs
448    /// inside one deferred read transaction (`BEGIN … ROLLBACK`) so the whole run sees one
449    /// consistent snapshot; under wal2 that reader never blocks the writer.
450    ///
451    /// It runs the **same** planner gate as [`register_query`](Self::register_query)
452    /// (`plan_queries && has_flippable_exists`), so the plan it reports is the one a *fresh*
453    /// registration would choose — the "current-stats plan," which for a long-lived
454    /// materialization whose stats have drifted can legitimately differ from the frozen plan
455    /// actually running (§3.1). `BuildError` (unknown table/column, unsupported shape)
456    /// surfaces synchronously; an internal-invariant *panic* (e.g. a cost-model assert —
457    /// planning runs before `build_pipeline` validates table names) is contained and
458    /// surfaced as an error, exactly like [`register_query`](Self::register_query).
459    pub fn analyze_query(&self, ast: &Ast) -> Result<AnalyzeReport, ReplicaError> {
460        // One fresh read handle + schema per registered table for this run. The forks
461        // share the worker connection — safe, because the engine is idle here (analyze
462        // is a between-commits FIFO command), so its connection is free for the pinned
463        // snapshot. The live sources are byte-for-byte untouched.
464        let tables: HashMap<String, (TableSource, SourceSchema)> = self
465            .sources
466            .iter()
467            .map(|(table, (_, schema))| {
468                let fork = self
469                    .scalar_sources
470                    .get(table)
471                    .expect("every registered table has a build-time read fork")
472                    .fork();
473                (table.clone(), (fork, schema.clone()))
474            })
475            .collect();
476        analyze_on(
477            &self.worker,
478            &tables,
479            self.plan_queries,
480            self.operator_storage,
481            ast,
482        )
483    }
484
485    /// The hierarchical view [`Schema`] `ast` materializes to — derived from the registered
486    /// sources exactly as [`register_query`](Engine::register_query) resolves them (so the
487    /// shape, sort, `singular` flag, and in-view relationships line up with the change
488    /// stream). Protocol-agnostic; the flat-change / remote layer turns it into a wire
489    /// schema + fingerprint. `BuildError` (unknown table/column) surfaces as `Err`.
490    pub fn view_schema(&self, ast: &Ast) -> Result<Schema, ReplicaError> {
491        let sources = &self.sources;
492        let resolve = |t: &str| sources.get(t).map(|(id, sc)| (*id, sc.clone()));
493        Ok(view_schema(ast, &resolve)?)
494    }
495
496    /// Tear down a registered query: drop its bookkeeping and reclaim its pipeline
497    /// (disconnect from the shared sources + free its operator/storage slots). Returns
498    /// `false` if `sink` was not a registered query (already gone). Idempotent and safe
499    /// against a stale sink id: a non-matching key is simply absent from `queries`.
500    pub fn deregister_query(&mut self, sink: NodeId) -> bool {
501        match self.queries.remove(&sink) {
502            Some(reg) => {
503                self.graph.destroy_pipeline(&reg.manifest);
504                true
505            }
506            None => false,
507        }
508    }
509
510    /// Tear down every query registered under `query_id` and reclaim its pipeline.
511    /// The parallel `Cluster` shards one `query_id` to one worker and tears down by
512    /// the stable `query_id` (a sink `NodeId` does not survive a fault rebuild);
513    /// duplicates registered under the same id are all removed. Returns the count
514    /// reclaimed (0 if none — idempotent / unknown id).
515    pub fn deregister_by_query_id(&mut self, query_id: QueryId) -> usize {
516        let sinks: Vec<NodeId> = self
517            .queries
518            .iter()
519            .filter(|(_, reg)| reg.query_id == query_id)
520            .map(|(sink, _)| *sink)
521            .collect();
522        sinks
523            .into_iter()
524            .filter(|&sink| self.deregister_query(sink))
525            .count()
526    }
527
528    /// The caller's [`QueryId`] for a registered query's sink, or `None` if `sink`
529    /// is not a live query (used to tag derived deltas for channel-out delivery).
530    pub fn query_id_of(&self, sink: NodeId) -> Option<QueryId> {
531        self.queries.get(&sink).map(|r| r.query_id)
532    }
533
534    /// The [`QueryId`]s of every query currently registered on this engine (used by
535    /// the parallel runtime to notify subscribers when the whole engine is torn down
536    /// and rebuilt after a derivation fault).
537    pub fn query_ids(&self) -> Vec<QueryId> {
538        self.queries.values().map(|r| r.query_id).collect()
539    }
540
541    /// Fetch `query_id` through its pipeline as a fresh set of hydration `Add`s.
542    /// This traverses current sources rather than reading a cached change-stream fold.
543    /// [`try_hydrate_change_sink`](rindle::graph::Graph::try_hydrate_change_sink)
544    /// leaves the push buffer unchanged, so it is safe to call
545    /// on an already-hydrated query between commits. `Ok(None)` if `query_id` is not
546    /// registered on this engine; `Err` when the read boundary raises (a parked leaf
547    /// error, or the §5.3 sum-overflow state a prior — itself typed-errored — write left
548    /// out of range: reads over unrepresentable state error every time, never render a
549    /// `NULL` aggregate as data).
550    pub fn read_snapshot(
551        &self,
552        query_id: QueryId,
553    ) -> Result<Option<Vec<CaughtChange>>, ReplicaError> {
554        let sink = self
555            .queries
556            .iter()
557            .find(|(_, reg)| reg.query_id == query_id)
558            .map(|(&sink, _)| sink);
559        match sink {
560            None => Ok(None),
561            // A family's snapshot is every bound partition's rows (design 310 §4.4).
562            Some(sink) => match self.queries.get(&sink).and_then(|r| r.family.as_ref()) {
563                Some(fam) => Ok(Some(self.graph.hydrate_family(fam, sink)?)),
564                None => Ok(Some(self.graph.try_hydrate_change_sink(sink)?)),
565            },
566        }
567    }
568
569    /// Whether opt-in cost-based query planning is enabled (so a post-fault rebuild can
570    /// inherit the setting).
571    pub fn plan_queries(&self) -> bool {
572        self.plan_queries
573    }
574
575    /// The operator-scratch-state backend this engine's graph uses (so a post-fault
576    /// rebuild re-creates the graph with the same backend).
577    pub fn operator_storage(&self) -> OperatorStorage {
578        self.operator_storage
579    }
580
581    /// Set the D4 derivation memory budget — the estimated bytes of folded rows ONE
582    /// transaction may hold across every table it touches. Every registered delta
583    /// already shares this budget, so the new ceiling is live immediately; tables
584    /// registered later — including by a shed/fault rebuild — inherit it.
585    pub fn set_max_delta_bytes(&self, max_bytes: usize) {
586        self.delta_budget.set_max_bytes(max_bytes);
587    }
588
589    /// The engine's current D4 budget ceiling (so a rebuild can carry it over).
590    pub fn max_delta_bytes(&self) -> usize {
591        self.delta_budget.max_bytes()
592    }
593
594    /// Set the join membership pre-check bounds on this engine's graph
595    /// (`designs/311-JOIN-MEMBERSHIP-PRECHECK-DESIGN.md` §8): the per-join distinct-key
596    /// bound (`None` = off, the default) and the per-graph key budget. A change resets
597    /// every join's set, which then rebuilds by observing its next hydrate-shaped fetch.
598    pub fn set_join_precheck_bounds(&self, per_join: Option<usize>, per_graph: usize) {
599        self.graph.set_join_precheck_bounds(per_join, per_graph);
600    }
601
602    /// The engine's current pre-check bounds (so a rebuild can carry them over).
603    pub fn join_precheck_bounds(&self) -> rindle::graph::JoinPrecheckBounds {
604        self.graph.join_precheck_bounds()
605    }
606
607    /// The operator scratch storage held by the pipeline(s) registered under `query_id`
608    /// — the entry count always, and every `(slot, key, value)` when `dump`. `None` if
609    /// nothing is registered under that id. Inspection-only (a full `scan` per storage
610    /// slot); the leak probe of design 310 impl plan D5 — after unbinding every partition
611    /// of a family, its pipeline's scratch state must be back at the count it held when
612    /// the bindings were all bound minus their slots, not carrying zombie partitions.
613    pub fn storage_report(&self, query_id: QueryId, dump: bool) -> Option<StorageReport> {
614        let mut found = false;
615        let mut out = StorageReport::default();
616        for reg in self.queries.values().filter(|r| r.query_id == query_id) {
617            found = true;
618            out.entries += self.graph.storage_entries_of(&reg.manifest);
619            if dump {
620                out.dump.extend(self.graph.storage_dump_of(&reg.manifest));
621            }
622        }
623        found.then_some(out)
624    }
625
626    /// A read-only report of the join membership pre-check on this engine: the bounds in
627    /// force, the graph-wide tracked-key charge, and — per registered query, sorted by
628    /// [`QueryId`] — the state of every join its pipeline owns. The inspection hook the
629    /// schedule tests use to compare a live worker against a fresh graph (design 311
630    /// §14.1: the knob used to land only at the next commit barrier, after the hydrate
631    /// that builds the sets, which no lane could see).
632    pub fn join_precheck_report(&self) -> JoinPrecheckReport {
633        let mut queries: Vec<(QueryId, Vec<JoinPrecheckState>)> = self
634            .queries
635            .values()
636            .map(|reg| {
637                (
638                    reg.query_id,
639                    self.graph.join_precheck_states_of(reg.manifest.nodes()),
640                )
641            })
642            .collect();
643        queries.sort_by_key(|(q, _)| q.0);
644        JoinPrecheckReport {
645            bounds: self.graph.join_precheck_bounds(),
646            tracked_keys: self.graph.join_precheck_tracked_keys(),
647            queries,
648        }
649    }
650
651    /// The connection this engine derives against, as the shared handle a shed/fault
652    /// rebuild needs to construct the replacement engine.
653    pub fn worker_conn(&self) -> Rc<Connection> {
654        self.worker.clone()
655    }
656
657    /// The connection this engine derives against (for ad-hoc reads/probes).
658    pub fn conn(&self) -> &Connection {
659        &self.worker
660    }
661
662    // --- The derivation, split into its three phases so a worker thread can
663    // interleave the snapshot/commit handshake between them (open + ack, derive,
664    // [writer commits], emit + roll back). The single-thread `Db` runs all three
665    // back-to-back via `apply_batch`. ---
666
667    /// Arm (or clear) the wall-clock deadline for the next push (FOLLOWER-LAG-SHED §6.6 —
668    /// the runaway-push bail). The worker brackets each `apply_and_drain` with it; the
669    /// graph's fan-out checkpoints park a `PushDeadlineExceeded` on expiry.
670    pub fn set_push_deadline(&self, deadline: Option<std::time::Instant>) {
671        self.graph.set_push_deadline(deadline);
672    }
673
674    /// **Phase 1.** Open the derivation snapshot: a transaction plus a forcing read
675    /// that PINS the read snapshot at the current (pre-commit = T-1) state.
676    /// A WAL read snapshot is established at the *first read*, not at `BEGIN`, so
677    /// the pin must happen here — before the writer commits the transaction being
678    /// derived — or a read during [`apply_and_drain`](Self::apply_and_drain) (after
679    /// the writer commits) could slide forward to the post-commit state and
680    /// double-count. Reads `sqlite_schema`, so it needs no registered table.
681    ///
682    /// The transaction is a plain deferred `BEGIN` — the derivation never writes;
683    /// batch state lives in each source's [`BatchDelta`], activated here (design 306).
684    ///
685    /// **Self-healing:** nothing but this method opens a transaction on the worker
686    /// connection, so a `BEGIN` that fails with one already open can only mean an
687    /// earlier derivation leaked its snapshot. Close it and retry once rather than
688    /// leaving the connection pinned at that stale WAL snapshot for the life of the
689    /// process (every later `open_snapshot` failing the same way, the shard serving
690    /// ever-older hydrations, and the WAL never checkpointing past the held read mark).
691    pub fn open_snapshot(&self) -> Result<(), ReplicaError> {
692        if let Err(e) = self.worker.execute_batch("BEGIN") {
693            if self.worker.is_autocommit() {
694                // Not a leaked transaction (busy, I/O, …) — the caller's fault to handle.
695                return Err(ReplicaError::sqlite(
696                    "worker BEGIN (derivation snapshot)",
697                    e,
698                ));
699            }
700            self.rollback(); // discard the leaked transaction + its overlays, then retry once
701            self.worker.execute_batch("BEGIN").map_err(|e| {
702                ReplicaError::sqlite(
703                    "worker BEGIN (derivation snapshot, after clearing a leaked transaction)",
704                    e,
705                )
706            })?;
707        }
708        if let Err(e) = self
709            .worker
710            .query_row("SELECT count(*) FROM sqlite_schema", [], |_| Ok(()))
711        {
712            // Don't leak the just-opened transaction if the forcing read failed.
713            let _ = self.worker.execute_batch("ROLLBACK");
714            return Err(ReplicaError::sqlite("worker snapshot forcing read", e));
715        }
716        for delta in self.deltas.values() {
717            delta.begin();
718        }
719        Ok(())
720    }
721
722    /// **Phase 2.** Push every *hosted* change in `batch` through the shared
723    /// sources — fanning each out to every dependent query — then drain each
724    /// affected sink. A change for a table this engine does not host is **skipped**
725    /// (another worker hosts it; the single-thread engine hosts every captured
726    /// table, so the skip is unreachable there). Returns `(sink, events)` per
727    /// changed query. On error, partial sink buffers are discarded so a failed tx
728    /// can't leak into the next. The snapshot must be open (Phase 1).
729    ///
730    /// The whole push+drain is run under a panic boundary so an internal-invariant
731    /// *panic* (an engine bug, never valid input) becomes an `Err` rather than
732    /// unwinding the worker thread: the per-push `source_push_isolated` already
733    /// catches the push fan-out, and this outer guard additionally covers the drain
734    /// loop. An `Err` here routes the worker to its in-place tear-down-on-fault
735    /// (discard + rebuild the engine) — keeping the worker thread alive instead of
736    /// escalating to a full thread death + respawn. Effective under `panic = "unwind"`.
737    pub fn apply_and_drain(
738        &self,
739        batch: &[Captured],
740    ) -> Result<Vec<(NodeId, Vec<CaughtChange>)>, ReplicaError> {
741        use std::panic::{catch_unwind, AssertUnwindSafe};
742        let result = match catch_unwind(AssertUnwindSafe(|| self.push_and_drain(batch))) {
743            Ok(r) => r,
744            Err(_) => Err(ReplicaError::Rindle(RindleError::Storage(
745                "internal panic during derive".to_string(),
746            ))),
747        };
748        if result.is_err() {
749            for sink in self.queries.keys().copied().collect::<Vec<_>>() {
750                let _ = self.graph.take_sink_changes(sink);
751            }
752        }
753        result
754    }
755
756    /// Apply **one** captured change to the shared sources, fanning it out to every
757    /// dependent query. A change for a table this engine does not host is **skipped**
758    /// (another worker hosts it; the single-thread engine hosts every captured table, so
759    /// the skip is unreachable there). Does **not** drain — the deltas this change
760    /// produces accumulate in the affected sinks until [`drain`](Self::drain). The
761    /// snapshot must be open (Phase 1).
762    ///
763    /// CONTRACT: a `SourceChange::Edit` is pk-stable — the primary key is row identity,
764    /// never mutated in place. The CDC captures an arbitrary SQL statement, so an
765    /// `UPDATE … SET <pk> = …` (legal, if pathological) arrives here as a single Edit
766    /// whose old/new pks differ. Normalize that ONE case into Remove(old)+Add(new) at
767    /// this single ingress, so every consumer — especially the pk-keyed NormalizeFold,
768    /// which would otherwise silently drop it — sees the honest row-identity change
769    /// (adversarial-review #13). All other producers honor the contract by construction.
770    /// The batch is shared (`Arc`) across workers; cloning a `SourceChange` is a refcount
771    /// bump, not a row copy. `source_push_isolated` catch_unwinds the operator-graph
772    /// fan-out + drains parked errors, so an internal-invariant panic surfaces as `Err`.
773    pub fn push(&self, cap: &Captured) -> Result<(), ReplicaError> {
774        // Skip-if-not-hosted: only push changes for tables we have a source for.
775        let Some(src) = self.sources.get(&*cap.table) else {
776            return Ok(());
777        };
778        self.push_resolved(src, cap)
779    }
780
781    /// [`push`](Self::push) with the table already resolved to its source — the seam that
782    /// lets [`push_and_drain`](Self::push_and_drain) hoist the name lookup out of the
783    /// per-row loop. Behavior is identical; only *who* did the `sources` lookup differs.
784    fn push_resolved(
785        &self,
786        (src_id, schema): &HostedSource,
787        cap: &Captured,
788    ) -> Result<(), ReplicaError> {
789        match &cap.change {
790            SourceChange::Edit { old, row } if pk_changed(&schema.primary_key, old, row) => {
791                self.graph
792                    .source_push_isolated(*src_id, SourceChange::Remove(old.clone()))?;
793                self.graph
794                    .source_push_isolated(*src_id, SourceChange::Add(row.clone()))?;
795            }
796            _ => {
797                self.graph
798                    .source_push_isolated(*src_id, cap.change.clone())?;
799            }
800        }
801        Ok(())
802    }
803
804    /// Take whatever deltas are pending in the sinks: drain every registered query's
805    /// change-sink and return `(sink, events)` for each that produced output (empty
806    /// sinks are skipped). Called once after a batch of [`push`](Self::push)es in the
807    /// batch model; per-change in the streaming model (where each push is followed
808    /// immediately by a drain so the worker holds nothing across a transaction).
809    pub fn drain(&self) -> Vec<(NodeId, Vec<CaughtChange>)> {
810        let sinks: Vec<NodeId> = self.queries.keys().copied().collect();
811        let mut out = Vec::new();
812        for sink in sinks {
813            let changes = self.graph.take_sink_changes(sink);
814            if !changes.is_empty() {
815                out.push((sink, changes));
816            }
817        }
818        out
819    }
820
821    fn push_and_drain(
822        &self,
823        batch: &[Captured],
824    ) -> Result<Vec<(NodeId, Vec<CaughtChange>)>, ReplicaError> {
825        // THE batch boundary for `rindle_apply_batch_seconds`. Scoped to cover the drain
826        // as well as the pushes: what an operator feels is "apply this batch and hand me
827        // its deltas", not the fan-out alone. Dropped on the `?` path too, so a failed
828        // apply still records the time it burned. Free with `metrics` off (ZST).
829        //
830        // NOT one observation per transaction, and the metric is named so it cannot be
831        // read as one: the coordinator cuts a transaction into `PUSH_CHUNK_ROWS` (1024)
832        // chunks and BROADCASTS each chunk to every worker, so a transaction of N rows
833        // under W workers produces `W · ceil(N / 1024)` observations here. Quantiles
834        // survive that (the W copies are identical); `_count`/`_sum` are ×W. See
835        // `rindle::metrics::ApplyBatch`.
836        //
837        // `batch.len()` is the row count OFFERED to this worker, which is ≥ what it
838        // pushes: the loop below skips changes for tables this worker does not host, while
839        // a pk-changing Edit fans into two pushes. `rindle_changes_processed_total` is the
840        // pushes-actually-made counter; these two are deliberately different questions.
841        let _batch = rindle::metrics::apply_batch(batch.len() as u64);
842        // One-entry memo over the table resolve, which is otherwise paid `rows × workers`
843        // times per transaction: every chunk is broadcast to EVERY worker (`parallel.rs`
844        // `StreamTx::push`) though only the shards holding a query on that table host it, so
845        // on all the others this name hash IS the per-row cost. The memo caches the NEGATIVE
846        // too (note the inner `Option`) — "not hosted here" is the common answer, and
847        // re-deriving it per row is exactly what is being removed.
848        //
849        // Exact, not heuristic: `Captured::table` is the interned key out of the CDC
850        // registry, so every row of a table carries the SAME `Arc` and `Arc::ptr_eq` hits
851        // for the whole run. Chunks are cut between statements (`cluster.rs` `maybe_pump`)
852        // and a statement writes one table, so a bulk chunk collapses to one hash instead
853        // of 1024. A miss (interleaved tables, e.g. a trigger cascade) costs one pointer
854        // compare and falls through to the lookup. A hit cannot be a false match: pointer
855        // equality implies the same allocation, and `batch` keeps every name it holds alive
856        // across the loop, so no address can be recycled underneath the memo.
857        let mut memo: Option<(&Arc<str>, Option<&HostedSource>)> = None;
858        for cap in batch {
859            let src = match memo {
860                Some((name, src)) if Arc::ptr_eq(name, &cap.table) => src,
861                _ => {
862                    let hit = self.sources.get(&*cap.table);
863                    memo = Some((&cap.table, hit));
864                    hit
865                }
866            };
867            if let Some(src) = src {
868                self.push_resolved(src, cap)?;
869            }
870        }
871        Ok(self.drain())
872    }
873
874    /// **Phase 3.** Discard the derivation state (the writer's COMMIT is the durable
875    /// copy): clear every table's [`BatchDelta`], then close the snapshot
876    /// transaction. Safe to call
877    /// once the snapshot was opened (Phase 1).
878    pub fn rollback(&self) {
879        for delta in self.deltas.values() {
880            delta.end();
881        }
882        let _ = self.worker.execute_batch("ROLLBACK");
883    }
884
885    /// Single-thread convenience (the `Db` path): the three phases back-to-back on
886    /// one thread — open the snapshot, derive, roll back. Returns `(sink, events)`
887    /// per changed query.
888    pub fn apply_batch(
889        &self,
890        captured: Vec<Captured>,
891    ) -> Result<Vec<(NodeId, Vec<CaughtChange>)>, ReplicaError> {
892        self.open_snapshot()?;
893        let result = self.apply_and_drain(&captured);
894        self.rollback();
895        result
896    }
897}
898
899/// Did any primary-key column actually change between an Edit's `old` and `new`? Uses the
900/// total, null-identical [`values_identical`] (a pk null in both is unchanged), so this is true
901/// only for a genuine pk mutation — which is then modeled as Remove(old)+Add(new), never an
902/// in-place Edit.
903fn pk_changed(pk: &[ColId], old: &OwnedRow, new: &OwnedRow) -> bool {
904    pk.iter()
905        .any(|&c| !values_identical(old.col(c), new.col(c)))
906}
907
908/// A [`ScalarCatalog`] over the engine's fork read-handles — the seam
909/// [`resolve_scalars`] uses to look up a statically-unique child row at build time.
910/// Borrows the `scalar_sources` map for the duration of one registration.
911struct ForkCatalog<'a>(&'a HashMap<String, TableSource>);
912
913impl ScalarCatalog for ForkCatalog<'_> {
914    fn source(&self, table: &str) -> Option<&dyn ScalarSource> {
915        self.0.get(table).map(|s| s as &dyn ScalarSource)
916    }
917}
918
919// ---------------------------------------------------------------------------
920// `analyze_query` support
921// ---------------------------------------------------------------------------
922
923/// Build a [`Graph`] with the operator-scratch backend `storage` selects — shared by
924/// [`Engine::new`] and [`analyze_on`], so an analyze run exercises (and costs) the same
925/// `take`/`reduce` scratch backend the live engine is configured with rather than
926/// silently buffering in memory.
927fn build_graph(storage: OperatorStorage) -> Result<Graph, ReplicaError> {
928    Ok(match storage {
929        OperatorStorage::Memory => Graph::new(),
930        OperatorStorage::SqliteSpill => {
931            // A private temp-file scratch DB confined to this thread (the connection is
932            // `!Send`, created here, dropped with the graph). Each stateful operator
933            // gets a unique `op_id`-namespaced keyspace inside the database's shared
934            // `storage` table.
935            let db = DatabaseStorage::new_temp_file()
936                .map_err(|e| ReplicaError::sqlite("open operator scratch db", e))?;
937            Graph::with_storage_factory(StorageFactory::custom(Rc::new(db)))
938        }
939    })
940}
941
942/// A [`ScalarCatalog`] over one analyze run's own read handles (the per-run twin of
943/// [`ForkCatalog`], which borrows the engine's long-lived fork map).
944struct AnalyzeCatalog<'a>(&'a HashMap<String, (TableSource, SourceSchema)>);
945
946impl ScalarCatalog for AnalyzeCatalog<'_> {
947    fn source(&self, table: &str) -> Option<&dyn ScalarSource> {
948        self.0.get(table).map(|(s, _)| s as &dyn ScalarSource)
949    }
950}
951
952/// Everything one standalone `analyze query` run needs, snapshotted off the live
953/// engine so the run itself can happen on **any** thread: the DB path, the registered
954/// tables, and the engine configuration. Built by
955/// [`Cluster::analyze_spec`](crate::Cluster::analyze_spec); consumed by
956/// [`analyze_standalone`]. Plain owned data (`Send`), no engine handles.
957pub struct AnalyzeSpec {
958    pub(crate) path: PathBuf,
959    pub(crate) tables: Vec<(String, TableSchema)>,
960    pub(crate) plan_queries: bool,
961    pub(crate) operator_storage: OperatorStorage,
962}
963
964/// Run one `analyze query` COLD on the **calling thread** over a **private** connection
965/// to `spec.path`, touching no engine, no worker, and no live statement cache.
966///
967/// This is the daemon's `/analyze` entry point (run on a dedicated thread there): because
968/// everything is analysis-private — a fresh connection with the standard pragmas
969/// (`open_journal` — the same opener the workers use), fresh `TableSource`s, a throwaway
970/// graph — a genuinely slow cold hydrate blocks only its own thread; commits,
971/// materializations, and control-plane commands proceed. Isolation also keeps the
972/// numbers honest: the `scan-stats` counters live on this connection's private prepared
973/// statements, so a live cursor's history can never inflate them. Under wal2 the pinned
974/// deferred read snapshot never blocks the writer.
975pub fn analyze_standalone(spec: &AnalyzeSpec, ast: &Ast) -> Result<AnalyzeReport, ReplicaError> {
976    // Analysis-only: this connection hydrates and counts, and never writes a row, so the
977    // foreign-key posture is inert here — take the default rather than inventing one.
978    let conn = Rc::new(open_journal(
979        &spec.path,
980        JournalMode::default(),
981        crate::ForeignKeys::default(),
982    )?);
983    let mut tables: HashMap<String, (TableSource, SourceSchema)> = HashMap::new();
984    for (table, ts) in &spec.tables {
985        // Mirror `Engine::register_table`: the same source construction and the same
986        // source-schema mapping, so the pipeline this run builds is the one the daemon
987        // would run.
988        let col_names: Vec<&str> = ts.columns.iter().map(|c| &*c.name).collect();
989        let sort: Sort = ts.primary_key.iter().map(|&c| (c, true)).collect();
990        let schema = SourceSchema::new(col_names, ts.primary_key.clone(), sort)
991            .with_column_types(ts.columns.iter().map(|c| c.ty).collect());
992        let source = TableSource::try_new(
993            conn.clone(),
994            table,
995            ts.columns.clone(),
996            ts.primary_key.clone(),
997        )?;
998        tables.insert(table.clone(), (source, schema));
999    }
1000    analyze_on(
1001        &conn,
1002        &tables,
1003        spec.plan_queries,
1004        spec.operator_storage,
1005        ast,
1006    )
1007}
1008
1009/// The core of one `analyze query` run over `conn` (`ANALYZE-QUERY-DESIGN.md`): pin a
1010/// read snapshot, lower `ast` through the same scalar-resolve → planner gate as
1011/// `register_query` into a throwaway instrumented pipeline over `tables`, hydrate it
1012/// cold, and assemble the report. Shared by [`Engine::analyze_query`] (worker connection
1013/// and engine catalog) and [`analyze_standalone`] (private connection, off every engine
1014/// thread).
1015///
1016/// The whole run — planning included — sits under a panic boundary mirroring
1017/// `register_query`'s: an internal-invariant panic (e.g. a cost-model assert on an AST
1018/// that names an unknown table, which planning hits *before* `build_pipeline` validates
1019/// it) surfaces as an error instead of killing the calling thread. The throwaway graph
1020/// is function-local, so unwind cleanup is just its drop; the read transaction rolls
1021/// back via the RAII guard on every exit path.
1022fn analyze_on(
1023    conn: &Rc<Connection>,
1024    tables: &HashMap<String, (TableSource, SourceSchema)>,
1025    plan_queries: bool,
1026    operator_storage: OperatorStorage,
1027    ast: &Ast,
1028) -> Result<AnalyzeReport, ReplicaError> {
1029    use std::panic::{catch_unwind, AssertUnwindSafe};
1030
1031    // Pin one consistent snapshot across every leaf fetch; ROLLBACK on ANY exit path
1032    // (early `?`, panic, normal return) via the RAII guard.
1033    conn.execute_batch("BEGIN")
1034        .map_err(|e| ReplicaError::sqlite("analyze: begin read snapshot", e))?;
1035    let _read_txn = RollbackOnDrop(conn);
1036
1037    let outcome = catch_unwind(AssertUnwindSafe(|| {
1038        // 1. A throwaway graph (same operator backend as the live one), one fresh
1039        //    instrumented source per table: a `CountingSource` counts the rows the
1040        //    fork vends, and (under the `analyze` feature) a `ScanSink` records its
1041        //    SQLite scan work.
1042        let mut graph = build_graph(operator_storage)?;
1043        let mut metas: HashMap<String, AnalyzeSourceMeta> = HashMap::new();
1044        for (table, (source, schema)) in tables {
1045            let base = source.fork();
1046            #[cfg(feature = "analyze")]
1047            let scan = {
1048                let sink: rindle_sqlite::ScanSink =
1049                    Rc::new(Cell::new(rindle_sqlite::ScanStats::default()));
1050                base.set_scan_sink(sink.clone());
1051                sink
1052            };
1053            #[cfg(feature = "analyze")]
1054            let plan: rindle_sqlite::PlanSink = {
1055                let sink: rindle_sqlite::PlanSink = Rc::new(RefCell::new(None));
1056                base.set_plan_sink(sink.clone());
1057                sink
1058            };
1059            let counter: EmitCounter = Rc::new(Cell::new(0));
1060            let counting = CountingSource::new(Box::new(base), counter.clone());
1061            let node = graph.add_dyn_source(Box::new(counting));
1062            metas.insert(
1063                table.clone(),
1064                AnalyzeSourceMeta {
1065                    node,
1066                    counter,
1067                    schema: schema.clone(),
1068                    #[cfg(feature = "analyze")]
1069                    scan,
1070                    #[cfg(feature = "analyze")]
1071                    plan,
1072                },
1073            );
1074        }
1075
1076        // 2. `resolve` records the tables the pipeline actually references (in first-fetch
1077        //    order), so the report lists exactly the leaves the query touches — a referenced
1078        //    empty table still appears; an unreferenced registered table does not.
1079        let referenced = RefCell::new(Vec::<String>::new());
1080        let resolve = |t: &str| {
1081            metas.get(t).map(|m| {
1082                referenced.borrow_mut().push(t.to_string());
1083                (m.node, m.schema.clone())
1084            })
1085        };
1086
1087        // 3. Scalar resolution → plan gate: the register_query path verbatim, so the
1088        //    reported plan is the one the daemon would run. Timed (plan phase).
1089        let resolved;
1090        let ast: &Ast = if has_scalar_subquery(ast) {
1091            let catalog = AnalyzeCatalog(tables);
1092            resolved = resolve_scalars(ast, &catalog)?;
1093            &resolved
1094        } else {
1095            ast
1096        };
1097        let plan_start = Instant::now();
1098        let planning = plan_queries && has_flippable_exists(ast);
1099        let planned;
1100        let ast: &Ast = if planning {
1101            let model: Rc<dyn ConnectionCostModel> =
1102                Rc::new(CachingCostModel::new(SqliteCostModel::new(conn.clone())));
1103            planned = plan_ast(ast, model);
1104            &planned
1105        } else {
1106            ast
1107        };
1108        let plan_ms = elapsed_ms(plan_start);
1109
1110        // 4. Build the throwaway pipeline + wire its sink. Timed (build phase).
1111        let build_start = Instant::now();
1112        let top = build_pipeline(&mut graph, ast, &resolve)?;
1113        let sink = graph.add_change_sink(top);
1114        graph.set_sink_edge(top, sink);
1115        let build_ms = elapsed_ms(build_start);
1116
1117        // 5. Hydrate — the cold drain that drives every source fetch (and so all the
1118        //    emit/scan counting). Timed (hydrate phase, the headline).
1119        let hydrate_start = Instant::now();
1120        // Fallible drain: a leaf error parked mid-fetch (an unsafe integer, a failed
1121        // `sqlite3_step`) or a §5.3 overflow ends the run rather than reporting the
1122        // partial counts as truth.
1123        let initial = graph.try_hydrate_change_sink(sink)?;
1124        let hydrate_ms = elapsed_ms(hydrate_start);
1125        let output_rows = initial.len();
1126
1127        // 6. Read the counters back for exactly the referenced leaves (deduped — a
1128        //    self-join resolves a table twice into one source node) and assemble.
1129        let mut seen = std::collections::HashSet::new();
1130        let ordered: Vec<String> = referenced
1131            .into_inner()
1132            .into_iter()
1133            .filter(|t| seen.insert(t.clone()))
1134            .collect();
1135
1136        // Concrete `CREATE INDEX` lines for high-amplification leaves, derived once from
1137        // the planned AST + each table's PK column names (the SQLite-free static advisory).
1138        let pk_names: HashMap<String, Vec<String>> = tables
1139            .iter()
1140            .map(|(t, (_, sc))| {
1141                let names = sc
1142                    .primary_key
1143                    .iter()
1144                    .map(|&c| sc.columns[c].to_string())
1145                    .collect();
1146                (t.clone(), names)
1147            })
1148            .collect();
1149        let pks: BTreeMap<&str, &[String]> = pk_names
1150            .iter()
1151            .map(|(t, v)| (t.as_str(), v.as_slice()))
1152            .collect();
1153        let advisory_lines = advisories(ast, &pks);
1154
1155        let mut sources = Vec::with_capacity(ordered.len());
1156        let mut total_emitted = 0u64;
1157        let mut total_scanned: Option<u64> = None;
1158        for table in ordered {
1159            let m = &metas[&table];
1160            let emitted = m.counter.get();
1161            total_emitted += emitted;
1162            let (scanned, fullscan_steps, scanned_kind) = read_scan(m);
1163            if let Some(s) = scanned {
1164                total_scanned = Some(total_scanned.unwrap_or(0) + s);
1165            }
1166            let ratio = match scanned {
1167                Some(s) if emitted > 0 => Some(s as f64 / emitted as f64),
1168                _ => None,
1169            };
1170            // Advise only a leaf whose un-indexed scan work is pathologically *amplified*
1171            // vs. what it emitted — a per-parent re-scan, not a benign one-time full scan
1172            // (which a child-driven flip does inherently). The scanned/emitted amplification
1173            // is the design's diagnostic; `fullscan_steps` is the reliable v1 proxy for it.
1174            let advisory = match fullscan_steps {
1175                Some(fs) if is_amplified(fs, emitted) => advisory_lines.get(&table).cloned(),
1176                _ => None,
1177            };
1178            let sqlite_plan = read_plan(m, conn);
1179            sources.push(AnalyzeSource {
1180                node: m.node.idx,
1181                table,
1182                emitted,
1183                scanned,
1184                fullscan_steps,
1185                ratio,
1186                advisory,
1187                sqlite_plan,
1188                scanned_kind,
1189            });
1190        }
1191
1192        Ok(AnalyzeReport {
1193            plan: AnalyzePlan {
1194                planning,
1195                joins: collect_joins(ast),
1196                ast: serde_json::to_value(ast).unwrap_or(serde_json::Value::Null),
1197            },
1198            timing: AnalyzeTiming {
1199                plan_ms,
1200                build_ms,
1201                hydrate_ms,
1202            },
1203            sources,
1204            totals: AnalyzeTotals {
1205                scanned: total_scanned,
1206                emitted: total_emitted,
1207                output_rows,
1208            },
1209        })
1210        // `graph` drops → the throwaway pipeline is freed.
1211    }));
1212
1213    match outcome {
1214        Ok(result) => result,
1215        // An internal-invariant panic mid plan/build/hydrate: the throwaway graph was
1216        // dropped during the unwind and `_read_txn` still rolls back — report the error
1217        // instead of letting the unwind kill the calling thread.
1218        Err(_panic) => Err(ReplicaError::Rindle(RindleError::Storage(
1219            "internal panic during analyze".to_string(),
1220        ))),
1221    }
1222    // `_read_txn` drops → ROLLBACK.
1223}
1224
1225/// Per-source scratch for one [`Engine::analyze_query`] run: the throwaway source's
1226/// `NodeId`, its live emit counter, its schema (for the `resolve` closure), and — under
1227/// the `analyze` feature — its shared SQLite scan-work sink.
1228struct AnalyzeSourceMeta {
1229    node: NodeId,
1230    counter: EmitCounter,
1231    schema: SourceSchema,
1232    #[cfg(feature = "analyze")]
1233    scan: rindle_sqlite::ScanSink,
1234    /// Captures this leaf's representative fetch SQL, re-`EXPLAIN`ed after hydration for
1235    /// the per-leaf access-path text.
1236    #[cfg(feature = "analyze")]
1237    plan: rindle_sqlite::PlanSink,
1238}
1239
1240/// Read a source's SQLite scan work back after hydration: `(scanned, fullscan_steps,
1241/// kind)`. The `scanned` proxy is `vm_steps` (design §3.3); a v1 **work** proxy, not a
1242/// row count. Without the `analyze` feature (or on a memory source) there is nothing to
1243/// read, so both numbers are `None`.
1244#[cfg(feature = "analyze")]
1245fn read_scan(m: &AnalyzeSourceMeta) -> (Option<u64>, Option<u64>, &'static str) {
1246    let s = m.scan.get();
1247    (
1248        Some(s.vm_steps),
1249        Some(s.fullscan_steps),
1250        "vm_steps (work proxy)",
1251    )
1252}
1253
1254#[cfg(not(feature = "analyze"))]
1255fn read_scan(_m: &AnalyzeSourceMeta) -> (Option<u64>, Option<u64>, &'static str) {
1256    (None, None, "unavailable")
1257}
1258
1259/// The per-leaf SQLite access-path text: re-`EXPLAIN` the representative fetch SQL this
1260/// source captured during hydration (`conn` is the analyze read connection). `None` if the
1261/// leaf ran no SQL (e.g. never fetched) or the plan is empty. Feature-off ⇒ nothing to read.
1262#[cfg(feature = "analyze")]
1263fn read_plan(m: &AnalyzeSourceMeta, conn: &Connection) -> Option<String> {
1264    let sql = m.plan.borrow().clone()?;
1265    rindle_sqlite::explain_plan(conn, &sql)
1266}
1267
1268#[cfg(not(feature = "analyze"))]
1269fn read_plan(_m: &AnalyzeSourceMeta, _conn: &Connection) -> Option<String> {
1270    None
1271}
1272
1273/// Milliseconds elapsed since `start`, as an `f64` (sub-ms resolution for the timing
1274/// report).
1275fn elapsed_ms(start: Instant) -> f64 {
1276    start.elapsed().as_secs_f64() * 1000.0
1277}
1278
1279/// Whether a leaf's un-indexed scan work is pathologically amplified relative to what it
1280/// emitted — the v1 "a missing index made SQLite re-scan this table" trigger
1281/// (`ANALYZE-QUERY-DESIGN.md` §3.5). A present index keeps `fullscan_steps` ~0; a benign
1282/// one-time full scan keeps it ≈ what it emits; only a **per-parent** re-scan drives it
1283/// many-fold above `emitted`. The floor keeps a tiny table (where a full scan is cheap and
1284/// an index wouldn't pay) from tripping it.
1285fn is_amplified(fullscan_steps: u64, emitted: u64) -> bool {
1286    /// A full scan under this many steps is cheaper than the index write cost — never advise.
1287    const FLOOR: u64 = 64;
1288    /// The scanned-work-per-emitted-row multiple that reads as "an index would collapse this."
1289    const FACTOR: f64 = 8.0;
1290    fullscan_steps >= FLOOR && (fullscan_steps as f64) >= (emitted.max(1) as f64) * FACTOR
1291}
1292
1293/// RAII: `ROLLBACK` the analyze read transaction on any exit path (early `?`, panic in an
1294/// unwinding build, or normal return). Analyze never writes, so a rollback is always the
1295/// right close.
1296struct RollbackOnDrop<'a>(&'a Connection);
1297
1298impl Drop for RollbackOnDrop<'_> {
1299    fn drop(&mut self) {
1300        let _ = self.0.execute_batch("ROLLBACK");
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    /// A derivation snapshot that was never closed (its worker was dropped from the
1309    /// transaction stream, so no terminal command ever reached it) must not wedge the
1310    /// connection: the next `open_snapshot` clears the leaked transaction and retries,
1311    /// and what it opens is a **real** pin, not a no-op inherited from the leak.
1312    ///
1313    /// Without the retry, `BEGIN` fails "cannot start a transaction within a transaction"
1314    /// for the life of the process — every push on that shard is skipped, every commit
1315    /// faults, and hydrations serve an ever-older base.
1316    #[test]
1317    fn open_snapshot_clears_a_leaked_transaction_and_retries() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let path = dir.path().join("leak.db");
1320        {
1321            let c =
1322                open_journal(&path, JournalMode::default(), crate::ForeignKeys::default()).unwrap();
1323            c.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
1324                .unwrap();
1325        }
1326        let conn = Rc::new(
1327            open_journal(&path, JournalMode::default(), crate::ForeignKeys::default()).unwrap(),
1328        );
1329        let engine = Engine::new(conn.clone(), false, OperatorStorage::default()).unwrap();
1330
1331        engine.open_snapshot().unwrap();
1332        assert!(
1333            !conn.is_autocommit(),
1334            "the snapshot holds a transaction open"
1335        );
1336        // …and now LEAK it: no `rollback`, straight into the next transaction's open.
1337        engine
1338            .open_snapshot()
1339            .expect("the leaked transaction is cleared and the BEGIN retried");
1340        assert!(!conn.is_autocommit(), "the retry left a transaction open");
1341
1342        // The retried snapshot really is pinned at *this* state: a row another connection
1343        // commits after it is invisible until the rollback.
1344        {
1345            let w =
1346                open_journal(&path, JournalMode::default(), crate::ForeignKeys::default()).unwrap();
1347            w.execute("INSERT INTO t (id, v) VALUES (1, 10)", [])
1348                .unwrap();
1349        }
1350        let count = |c: &Connection| {
1351            c.query_row("SELECT count(*) FROM t", [], |r| r.get::<_, i64>(0))
1352                .unwrap()
1353        };
1354        assert_eq!(
1355            count(&conn),
1356            0,
1357            "the re-opened snapshot pins the pre-insert state"
1358        );
1359        engine.rollback();
1360        assert!(conn.is_autocommit(), "rollback closes the transaction");
1361        assert_eq!(
1362            count(&conn),
1363            1,
1364            "and the connection moves on to current state"
1365        );
1366    }
1367}