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(¶m.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}