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(®.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(®.manifest);
619 if dump {
620 out.dump.extend(self.graph.storage_dump_of(®.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}