Skip to main content

linkrs_query/executor/streaming/
instance.rs

1//! QueryExecutionInstance: single-entry instantiation point for physical plans.
2//!
3//! The production entry path:
4//!
5//! ```text
6//! Arc<PhysicalPlan> + QueryBindings + ResultSink
7//!     → QueryExecutionInstance::instantiate_plan
8//!     → ExecutionRuntime (created once, shared by engine/operators/handle)
9//!     → operator tree (materialized from arena plan)
10//!     → delivery via ResultSink
11//! ```
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use parking_lot::RwLock;
17
18use super::engine::StreamingExecutionEngine;
19use super::plan::materializer::PhysicalPlanMaterializer;
20use super::plan::types::{PhysicalOperatorId, PhysicalPlan};
21use super::plan::validator::PhysicalPlanValidator;
22use super::result_utils::convert_chunks_to_dataset_with_spill;
23use super::runtime::ExecutionRuntime;
24use super::stream_result::StreamingQueryResult;
25use crate::executor::base::{ExecutionResult, MemoryBudget};
26use crate::optimizer::stats::feedback::history::QueryFeedbackHistory;
27use crate::optimizer::stats::feedback::query::{OperatorFeedback, QueryExecutionFeedback};
28use crate::storage::QueryStorage;
29use linkrs_core::error::QueryError;
30use linkrs_core::Arena;
31use linkrs_core::Value;
32
33use super::parameters::{ParameterFrame, ParameterSchema};
34use super::query_registry::{CancelToken, QueryGuard, QueryId, QueryMetadata, QueryRegistry};
35use super::transaction_scope::TransactionScope;
36use crate::query_manager::QueryManager;
37
38// ── QueryBindings ───────────────────────────────────────────────────────────
39
40/// Per-execution bindings that parameterize a [`PhysicalPlan`].
41///
42/// Unlike the plan (immutable, cacheable), `QueryBindings` carries data that
43/// changes per invocation: parameter values, storage handles, transaction
44/// scope, memory budget, and delivery preferences.
45///
46/// Multiple concurrent executions of the same plan each have their own
47/// `QueryBindings`, ensuring no mutable state is shared across instances.
48///
49/// carries a [`ParameterFrame`] for slot-based parameter access at
50/// execution time, built during validation from the plan's parameter schema.
51#[derive(Clone)]
52pub struct QueryBindings {
53    /// Prepared-statement parameter values (name → value map for validation).
54    pub parameters: Arc<HashMap<String, Value>>,
55    /// Session variable snapshot (name → value map), captured once per
56    /// statement at the API layer and resolved by `Expression::SessionVariable`.
57    pub session_variables: Arc<HashMap<String, Value>>,
58    /// slot-indexed parameter frame for hot-path access.
59    pub parameter_frame: Option<ParameterFrame>,
60    /// Target space name.
61    pub space_name: Option<String>,
62    /// Storage client for this execution.
63    pub storage: Option<Arc<RwLock<dyn QueryStorage>>>,
64    /// Snapshot handle pinned by the bound storage (storage boundary).
65    ///
66    /// Populated by the pipeline when the per-query storage is bound to a
67    /// read/auto-commit operation context. `None` for unbound storage.
68    pub bound_snapshot: Option<crate::storage::SnapshotHandle>,
69    /// Per-query memory budget.
70    pub memory_budget: MemoryBudget,
71    /// Maximum intra-query worker threads (1 = serial only).
72    pub max_workers: usize,
73    /// Rows per output chunk.
74    pub chunk_size: usize,
75    /// Max buffered chunks per partition channel before back-pressure.
76    pub max_buffered_chunks: usize,
77    /// Server-assigned query ID (for KILL QUERY / metrics).
78    pub query_id: u64,
79    /// Request-scoped cancellation token.
80    ///
81    /// When present it is shared with the query registry and the execution
82    /// runtime so `QueryContext::mark_killed`, KILL QUERY, and runtime cancel
83    /// all flip the same underlying state.
84    pub cancel_token: Option<CancelToken>,
85    /// Query text for diagnostics and logging.
86    pub query_text: Option<String>,
87    /// Session ID for the executing session.
88    pub session_id: Option<String>,
89    /// User name for the executing session.
90    pub user_name: Option<String>,
91    /// Transaction scope for this execution.
92    pub transaction: TransactionScope,
93    /// Engine-level shared scheduler.  When set, all queries share the
94    /// same worker pool instead of creating per-query threads.
95    pub shared_scheduler: Option<Arc<super::pool::SharedScheduler>>,
96    /// Number of partitions for partitioned execution. 0 = non-partitioned.
97    pub partition_count: usize,
98    /// Optional thread-safe bumpalo arena for executor temporary allocations.
99    pub arena: Option<Arc<parking_lot::Mutex<Arena>>>,
100    /// Shared query feedback history for collecting execution statistics.
101    ///
102    /// When set, the execution instance records estimated-vs-actual operator
103    /// feedback here after execution completes (stats feedback loop).
104    /// Injected by the pipeline from the optimizer engine.
105    pub feedback_history: Option<Arc<QueryFeedbackHistory>>,
106    /// Shared cross-query policy for the typed columnar chunk layout.
107    ///
108    /// When set, the per-query columnar hit/miss counts are merged back into
109    /// the policy when the query finishes (columnar auto-detection).
110    /// Injected by the pipeline from the optimizer engine.
111    pub columnar_policy: Option<Arc<super::chunk::ColumnarPolicy>>,
112    /// Macro catalog manager (user-defined macros), shared engine-wide.
113    pub macro_manager: Option<Arc<linkrs_core::metadata::MacroManager>>,
114    /// Type-alias catalog manager (user-defined types), shared engine-wide.
115    pub type_alias_manager: Option<Arc<linkrs_core::metadata::TypeAliasManager>>,
116    /// Sequence catalog manager (named sequences), shared engine-wide.
117    pub sequence_manager: Option<Arc<linkrs_core::metadata::SequenceManager>>,
118    pub search: crate::executor::base::SearchContext,
119}
120
121impl std::fmt::Debug for QueryBindings {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("QueryBindings")
124            .field("parameters", &self.parameters)
125            .field("space_name", &self.space_name)
126            .field("query_id", &self.query_id)
127            .finish_non_exhaustive()
128    }
129}
130
131impl QueryBindings {
132    /// Build bindings from an [`ExecutionContext`] and a transaction scope.
133    pub fn from_context(
134        context: &crate::executor::base::ExecutionContext,
135        transaction: TransactionScope,
136    ) -> Self {
137        Self {
138            parameters: context.parameters.clone(),
139            session_variables: context.session_variables.clone(),
140            parameter_frame: None,
141            space_name: context.space_name.clone(),
142            storage: context.storage.clone(),
143            bound_snapshot: context.bound_snapshot,
144            memory_budget: context.memory_budget.clone(),
145            max_workers: context.max_workers,
146            chunk_size: context.chunk_size,
147            max_buffered_chunks: context.max_buffered_chunks,
148            query_id: context.query_id,
149            cancel_token: context.cancel_token.clone(),
150            query_text: None,
151            session_id: None,
152            user_name: None,
153            transaction,
154            shared_scheduler: context.shared_scheduler.clone(),
155            partition_count: 0,
156            arena: context.arena.clone(),
157            feedback_history: context.feedback_history.clone(),
158            columnar_policy: context.columnar_policy.clone(),
159            macro_manager: context.macro_manager.clone(),
160            type_alias_manager: context.type_alias_manager.clone(),
161            sequence_manager: context.sequence_manager.clone(),
162            search: context.search.clone(),
163        }
164    }
165
166    /// Build a [`ParameterFrame`] from the plan's parameter schema and the
167    /// binding values.  Called after validation during materialization.
168    ///
169    /// produces a slot-indexed frame that operators can read without
170    /// string-based lookup.
171    pub fn build_parameter_frame(&mut self, schema: &ParameterSchema) {
172        let mut values = Vec::with_capacity(schema.params.len());
173        for param in &schema.params {
174            let value = self
175                .parameters
176                .get(&param.name)
177                .cloned()
178                .or_else(|| param.default.clone())
179                .unwrap_or(linkrs_core::Value::Null(Default::default()));
180            values.push(value);
181        }
182        self.parameter_frame = Some(ParameterFrame::new(values));
183    }
184}
185
186// ── Delivery sink ───────────────────────────────────────────────────────────
187
188/// Delivery mechanism for query results.
189///
190/// The sink is chosen at instantiation time.  Schema is always available
191/// before the first data row.  Changing the sink does not rebuild the plan.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum ResultSink {
194    /// Materialize all chunks into a single [`ExecutionResult`].
195    Materialize,
196    /// Stream chunks one-at-a-time through a thread-safe handle.
197    Stream,
198    /// Discard all output (for side-effect-only commands).
199    Discard,
200}
201
202// ── QueryExecutionInstance ──────────────────────────────────────────────────
203
204/// Per-operator profile totals summed across partition instances.
205///
206/// One partitioned execution contributes a single sample per operator:
207/// per-partition actuals must never be compared against the whole
208/// operator estimate individually.
209struct AggregatedOperatorProfile {
210    name: String,
211    output_rows: u64,
212    input_rows: u64,
213    time_us: u64,
214    loops: u64,
215    partitions: u32,
216    exhausted: bool,
217}
218
219impl AggregatedOperatorProfile {
220    fn default() -> Self {
221        Self {
222            name: String::new(),
223            output_rows: 0,
224            input_rows: 0,
225            time_us: 0,
226            loops: 0,
227            partitions: 0,
228            // AND-fold across partitions: start true so the first
229            // instance decides instead of a false default.
230            exhausted: true,
231        }
232    }
233}
234
235/// A single execution instance of a [`PhysicalPlan`].
236///
237/// Owns the runtime, root memory pool, operator tree, task group, and
238/// delivery state for exactly one query invocation.  Multiple concurrent
239/// executions of the same plan produce separate `QueryExecutionInstance`
240/// values.
241///
242/// Optionally holds a [`QueryGuard`] that unregisters the query
243/// from the [`QueryRegistry`] on drop, ensuring no leaked entries.
244pub struct QueryExecutionInstance {
245    plan: Arc<PhysicalPlan>,
246    _bindings: QueryBindings,
247    runtime: Arc<ExecutionRuntime>,
248    engine: Option<StreamingExecutionEngine>,
249    sink: ResultSink,
250    /// Guard that unregisters from the query registry on drop.
251    _registry_guard: Option<QueryGuard>,
252}
253
254impl QueryExecutionInstance {
255    /// Instantiate from a [`PhysicalPlan`] arena (sole production path).
256    ///
257    /// Uses [`PhysicalPlanMaterializer`] to convert the arena plan into an
258    /// operator tree, then wraps it with runtime, engine, and sink.
259    ///
260    /// When a [`QueryRegistry`] is provided, the query is registered
261    /// with a unique non-zero ID and the guard is stored in the instance.
262    pub fn instantiate_plan(
263        plan: Arc<PhysicalPlan>,
264        bindings: QueryBindings,
265        sink: ResultSink,
266        registry: Option<Arc<QueryRegistry>>,
267        query_manager: Option<Arc<QueryManager>>,
268    ) -> Result<Self, QueryError> {
269        // Validate the plan (structural + binding).
270        PhysicalPlanValidator::validate(&plan)?;
271
272        // Materialize arena plan → executor tree via materializer.
273        let (executor, runtime) = PhysicalPlanMaterializer::materialize(&plan, &bindings)?;
274
275        // Adopt the request-scoped cancellation token on the runtime
276        // regardless of whether a registry is attached.  This is the
277        // single-token convergence point: `QueryContext::mark_killed`, KILL
278        // QUERY, and runtime cancel all flip the SAME underlying state, so
279        // pipelines without a shared registry (pure streaming / embedded
280        // paths) still honor mark_killed here.
281        runtime.set_cancel_token(bindings.cancel_token.clone().unwrap_or_default());
282
283        // Register with the query registry.
284        let registry_guard = registry.as_ref().map(|reg| {
285            let session_id = bindings
286                .session_id
287                .as_ref()
288                .and_then(|s| s.parse::<i64>().ok());
289            let meta = QueryMetadata {
290                query_id: QueryId(bindings.query_id.max(1)),
291                session_id,
292                user_name: bindings.user_name.clone(),
293                space_name: bindings.space_name.clone(),
294                query_text: bindings.query_text.clone(),
295                start_time: std::time::Instant::now(),
296            };
297            // Prefer a request-scoped query id when present so EXPLAIN/PROFILE,
298            // streaming, and materialized paths share one identity source;
299            // otherwise let the registry allocate a unique id.
300            //
301            // The external bindings token (request-scoped QueryContext token)
302            // is shared with the registry so mark_killed / KILL QUERY / runtime
303            // cancel all flip the SAME underlying cancellation state.
304            let (qid, token) = if bindings.query_id != 0 {
305                reg.register_with_token(
306                    QueryId(bindings.query_id),
307                    meta,
308                    bindings.cancel_token.clone(),
309                )
310            } else {
311                reg.register_with_token(QueryId(0), meta, bindings.cancel_token.clone())
312            };
313            runtime.assign_query_id(qid.as_u64());
314            runtime.set_query_registry(reg.clone(), qid);
315            // Attach the global QueryManager so KILL QUERY, finish tracking, and
316            // progress forwarding reach a single registry (the runtime is already
317            // shared as Arc, so this uses interior mutability).
318            if let Some(qm) = query_manager {
319                runtime.set_query_manager(qm);
320            }
321            // Re-adopt the registry-canonical token (when no external
322            // token was supplied the registry allocated its own), keeping
323            // the registry entry and the runtime on one cancellation
324            // source.
325            runtime.set_cancel_token(token);
326            QueryGuard::new(reg.clone(), qid)
327        });
328
329        // Set up the engine.
330        let mut engine = StreamingExecutionEngine::new();
331        engine.set_max_workers(bindings.max_workers);
332        engine.set_max_buffered_chunks(bindings.max_buffered_chunks);
333        engine.set_runtime(runtime.clone());
334        engine.register_executor(0, executor);
335
336        Ok(Self {
337            plan,
338            _bindings: bindings,
339            runtime,
340            engine: Some(engine),
341            sink,
342            _registry_guard: registry_guard,
343        })
344    }
345
346    /// Return a reference to the execution runtime.
347    pub fn runtime(&self) -> &Arc<ExecutionRuntime> {
348        &self.runtime
349    }
350
351    /// Return the output contract from the plan.
352    pub fn output_contract(&self) -> &super::plan::types::OutputContract {
353        &self.plan.output
354    }
355
356    /// Return the plan's fragment DAG.
357    pub fn fragment_graph(&self) -> &super::plan::types::FragmentGraph {
358        &self.plan.fragments
359    }
360
361    /// Execute and materialize all results.
362    ///
363    /// Valid only when the sink is [`ResultSink::Materialize`].
364    /// Panics if the sink has a different variant.
365    pub fn execute(&mut self) -> Result<ExecutionResult, QueryError> {
366        assert!(
367            matches!(self.sink, ResultSink::Materialize),
368            "QueryExecutionInstance::execute requires Materialize sink"
369        );
370
371        let mut engine = self
372            .engine
373            .take()
374            .ok_or_else(|| QueryError::execution("Engine already consumed".to_string()))?;
375        let chunks = engine.execute_collected()?;
376        self.collect_execution_feedback();
377        // Columnar auto-detection: merge this query's hit/miss counts into
378        // the shared policy so later queries can adapt.
379        self.runtime.flush_columnar_stats_to_policy();
380        let spill_manager = self.runtime.get_spill_manager();
381        let (dataset, spilled_rows, spilled_bytes, spilled_runs) =
382            convert_chunks_to_dataset_with_spill(
383                chunks,
384                Some(self.plan.output.output_layout.names()),
385                spill_manager,
386            )?;
387        if spilled_rows > 0 {
388            self.runtime.columnar_stats().record_spill_with_runs(
389                spilled_rows,
390                spilled_bytes,
391                spilled_runs,
392            );
393        }
394        Ok(ExecutionResult::DataSet { data: dataset })
395    }
396
397    /// Collect estimated-vs-actual execution feedback into the shared
398    /// [`QueryFeedbackHistory`] after execution completes.
399    ///
400    /// Compares the optimizer's per-operator row estimates (written into the
401    /// physical operator specs as `estimated_cardinality`) with the runtime
402    /// profile counters, and stores one [`QueryExecutionFeedback`] entry
403    /// keyed by the plan fingerprint.  Filter operators additionally carry
404    /// the normalized predicate key (`condition_key`) so the
405    /// statistics feedback loop can correct the selectivity of the specific
406    /// condition.
407    ///
408    /// Partition instances sharing one physical operator are summed into a
409    /// single per-execution sample first: folding per-partition profiles
410    /// independently would compare per-slice actuals against the whole
411    /// operator estimate and systematically bias selective predicates.
412    fn collect_execution_feedback(&self) {
413        let Some(history) = self.runtime().feedback_history.clone() else {
414            return;
415        };
416        let profile = self.runtime().profile().flush_to_collector();
417        let fingerprint = &self.plan.compatibility.fingerprint;
418        let mut feedback =
419            QueryExecutionFeedback::new(format!("v{}:{}", fingerprint.version, fingerprint.hash));
420        feedback.space = self._bindings.space_name.clone();
421
422        // Query-level estimate: the root operator's estimated cardinality.
423        feedback.estimated_rows = self
424            .plan
425            .fragments
426            .get(self.plan.root_fragment)
427            .and_then(|fragment| self.plan.operator(fragment.root_operator))
428            .and_then(|operator| operator.estimated_cardinality)
429            .map(|rows| rows as u64)
430            .unwrap_or(0);
431        feedback.actual_rows = profile.total_rows;
432        feedback.actual_time_us = profile.total_time_us;
433
434        let mut aggregates: HashMap<PhysicalOperatorId, AggregatedOperatorProfile> = HashMap::new();
435        for (key, op_profile) in &profile.operators {
436            let op_time_us =
437                op_profile.open_time_us + op_profile.next_time_us + op_profile.close_time_us;
438            // Track Apply vs SemiJoin rows and time so the decision feedback
439            // loop can compare the measured cost of the nested-loop / hash
440            // paths of subquery decorrelation.
441            match op_profile.name.as_str() {
442                "PatternApply" => {
443                    feedback.apply_rows += op_profile.output_rows;
444                    feedback.apply_time_us += op_time_us;
445                }
446                "SemiJoin" => {
447                    feedback.join_rows += op_profile.output_rows;
448                    feedback.join_time_us += op_time_us;
449                }
450                _ => {}
451            }
452            let aggregate = aggregates
453                .entry(key.physical_operator_id)
454                .or_insert_with(AggregatedOperatorProfile::default);
455            aggregate.output_rows += op_profile.output_rows;
456            aggregate.input_rows += op_profile.input_rows;
457            aggregate.time_us += op_time_us;
458            aggregate.loops += op_profile.advance_count.max(1);
459            aggregate.partitions += 1;
460            // One starved partition truncates the whole operator sample:
461            // partial counts must not train the feedback loop.
462            aggregate.exhausted = aggregate.exhausted && op_profile.exhausted;
463            if aggregate.name.is_empty() {
464                aggregate.name = op_profile.name.clone();
465            }
466        }
467
468        for (physical_id, aggregate) in &aggregates {
469            if let Some(operator) = self.plan.operator(*physical_id) {
470                if let Some(estimated) = operator.estimated_cardinality {
471                    // For filter operators, attach the normalized predicate
472                    // key so the feedback loop can correct the
473                    // selectivity of the specific condition; all other
474                    // cardinality-estimated operators carry a shape key so
475                    // the loop can correct their row counts.
476                    let condition_key = match &operator.spec {
477                        super::plan::types::OperatorKindSpec::Unary(
478                            super::operators::spec::UnarySpec::Filter { predicate, .. },
479                        ) => Some(crate::optimizer::cost::selectivity::condition_key(
480                            feedback.space.as_deref(),
481                            predicate,
482                        )),
483                        _ => None,
484                    };
485                    let shape_key = if condition_key.is_some() {
486                        None
487                    } else {
488                        crate::executor::streaming::operators::spec::operator_cardinality_shape_key(
489                            feedback.space.as_deref(),
490                            &operator.spec,
491                        )
492                    };
493                    feedback.add_operator_feedback(OperatorFeedback {
494                        operator_id: physical_id.0.to_string(),
495                        operator_type: aggregate.name.clone(),
496                        estimated_rows: estimated as u64,
497                        actual_rows: aggregate.output_rows,
498                        actual_input_rows: Some(aggregate.input_rows),
499                        partition_count: aggregate.partitions,
500                        exhausted: aggregate.exhausted,
501                        estimated_time_us: 0,
502                        actual_time_us: aggregate.time_us,
503                        execution_loops: aggregate.loops,
504                        condition_key,
505                        shape_key,
506                    });
507                }
508            }
509        }
510
511        history.add_feedback(feedback);
512    }
513
514    /// Convert to a streaming result handle.
515    ///
516    /// Valid only when the sink is [`ResultSink::Stream`].
517    /// Panics if the sink has a different variant.
518    pub fn into_stream(mut self) -> Result<StreamingQueryResult, QueryError> {
519        assert!(
520            matches!(self.sink, ResultSink::Stream),
521            "QueryExecutionInstance::into_stream requires Stream sink"
522        );
523
524        let engine = self
525            .engine
526            .take()
527            .ok_or_else(|| QueryError::execution("Engine already consumed".to_string()))?;
528        let stream = engine.execute()?;
529        let result = StreamingQueryResult::new_with_schema(
530            stream,
531            self.runtime.clone(),
532            self.plan.output.output_layout.names(),
533        );
534        // Columnar auto-detection: merge this query's hit/miss counts into
535        // the shared policy when the last streaming handle is dropped.
536        let runtime = self.runtime.clone();
537        result.set_on_drop(Box::new(move || runtime.flush_columnar_stats_to_policy()));
538        Ok(result)
539    }
540
541    /// Execute with a discard sink (for side-effect-only commands).
542    ///
543    /// Uses streaming internally to avoid materializing results that will be discarded.
544    pub fn execute_discard(&mut self) -> Result<(), QueryError> {
545        assert!(
546            matches!(self.sink, ResultSink::Discard),
547            "QueryExecutionInstance::execute_discard requires Discard sink"
548        );
549
550        let engine = self
551            .engine
552            .take()
553            .ok_or_else(|| QueryError::execution("Engine already consumed".to_string()))?;
554        let mut stream = engine.execute()?;
555        while stream.next_chunk()?.is_some() {}
556        stream.close()?;
557        self.runtime.flush_columnar_stats_to_policy();
558        Ok(())
559    }
560
561    /// Cancel the running query.
562    pub fn cancel(&self) {
563        self.runtime.cancel();
564    }
565}
566
567impl std::fmt::Debug for QueryExecutionInstance {
568    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569        f.debug_struct("QueryExecutionInstance")
570            .field("plan_operators", &self.plan.operator_count())
571            .field("fragments", &self.plan.fragment_count())
572            .field("sink", &self.sink)
573            .finish()
574    }
575}