Skip to main content

linkrs_query/executor/streaming/
engine.rs

1//! Streaming execution engine
2//!
3//! Pull-based streaming execution engine.
4//!
5//! Holds a single root executor and drives it via direct pull
6//! (`open → next → close`). The default remains serial. When explicitly
7//! configured, formal Gather nodes can use the bounded coordinator for
8//! partition-local, parallel-safe child trees.
9
10use std::sync::Arc;
11
12use super::chunk::DataChunk;
13use super::executor::{SortDirection, StreamingExecutor};
14use super::operators::base::OperatorBase;
15use super::operators::blocking::BlockingOperator;
16use super::operators::blocking::BlockingOperatorKind;
17use super::operators::gather_operator::GatherOperator;
18use super::runtime::ExecutionRuntime;
19use super::stream::ResultStream;
20use crate::executor::base::{MemoryBudget, MemoryTracker};
21use crate::executor::streaming::plan::types::SyntheticNodeIdAllocator;
22use crate::executor::streaming::pool::MorselWorkerPool;
23use crate::executor::streaming::spill::{SpillConfig, SpillManager};
24use linkrs_core::error::QueryError;
25use linkrs_core::types::expr::Expression;
26
27/// Streaming execution engine
28///
29/// Drives a single root executor (or multiple partition executors) via
30/// direct pull: open() → loop next() → close().
31///
32/// Partitioned roots retain their normal Gather semantics. Eligible local
33/// inputs may run through the bounded coordinator when `max_workers > 1`;
34/// all other trees retain the serial pull path.
35///
36/// An optional [`ExecutionRuntime`] can be attached to enable cancellation,
37/// memory tracking, and profiling.  Cancel checking and profile recording
38/// are built into [`StreamingExecutor`] dispatch in `executor.rs`.
39pub struct StreamingExecutionEngine {
40    root_executor: Option<Box<StreamingExecutor>>,
41    /// Partition executors for partitioned execution (one per partition).
42    partition_executors: Vec<StreamingExecutor>,
43    /// Number of local trees currently owned by a Gather root. This remains
44    /// meaningful after the `partition_executors` vector is cleared.
45    partition_count: usize,
46    /// Maximum worker threads for intra-query parallelism.
47    /// 1 (default) means fully serial.
48    max_workers: usize,
49    /// Per-partition output channel capacity for formal Gather nodes.
50    max_buffered_chunks: usize,
51    runtime: Option<Arc<ExecutionRuntime>>,
52    /// Allocator for synthetic node IDs (Gather, Start sources, etc.).
53    /// Replaces hardcoded sentinel values to avoid collision with real IDs.
54    synthetic_id_alloc: SyntheticNodeIdAllocator,
55    /// Spill configuration for blocking operators that support disk spill.
56    spill_config: SpillConfig,
57}
58
59impl StreamingExecutionEngine {
60    /// Create a new streaming execution engine
61    pub fn new() -> Self {
62        Self {
63            root_executor: None,
64            partition_executors: Vec::new(),
65            partition_count: 0,
66            max_workers: 1,
67            max_buffered_chunks: 10,
68            runtime: None,
69            synthetic_id_alloc: SyntheticNodeIdAllocator::new(),
70            spill_config: SpillConfig::default(),
71        }
72    }
73
74    /// Set the maximum number of worker threads for intra-query parallelism.
75    ///
76    /// Values below one are normalised to the serial fallback.
77    /// When a runtime is already attached and no shared scheduler is
78    /// configured, this creates a per-query `MorselWorkerPool`.
79    pub fn set_max_workers(&mut self, max_workers: usize) {
80        self.max_workers = max_workers.max(1);
81        if let Some(rt) = &self.runtime {
82            if rt.get_shared_scheduler().is_some() {
83                return;
84            }
85            if self.max_workers > 1 {
86                let pool = MorselWorkerPool::new(self.max_workers);
87                rt.set_worker_pool(Some(pool));
88            } else {
89                rt.set_worker_pool(None);
90            }
91        }
92    }
93
94    /// Returns the maximum number of worker threads.
95    pub fn max_workers(&self) -> usize {
96        self.max_workers
97    }
98
99    /// Set the spill configuration for blocking operators.
100    pub fn set_spill_config(&mut self, config: SpillConfig) {
101        self.spill_config = config;
102        if let Some(rt) = &self.runtime {
103            let qid = rt.query_id().query_id;
104            if let Ok(manager) = SpillManager::new(self.spill_config.clone(), qid) {
105                rt.set_spill_manager(Some(Arc::new(manager)));
106            }
107        }
108    }
109
110    /// Set the bounded output capacity used by worker channels.
111    pub fn set_max_buffered_chunks(&mut self, max_buffered_chunks: usize) {
112        self.max_buffered_chunks = max_buffered_chunks.max(1);
113        if let Some(rt) = &self.runtime {
114            rt.set_max_buffered_chunks(self.max_buffered_chunks);
115        }
116    }
117
118    /// Register the root executor.
119    ///
120    /// If a runtime is already attached, it is recursively injected
121    /// into the executor tree so that cancellation, profiling, and
122    /// resource tracking work regardless of registration order.
123    pub fn register_executor(&mut self, _executor_id: usize, executor: StreamingExecutor) {
124        if let Some(ref rt) = self.runtime {
125            // Must clone the executor before Box to call set_runtime
126            let mut boxed = Box::new(executor);
127            boxed.set_runtime(Some(rt.clone()));
128            self.root_executor = Some(boxed);
129        } else {
130            self.root_executor = Some(Box::new(executor));
131        }
132        self.partition_count = 0;
133        self.configure_registered_root_parallelism();
134    }
135
136    /// Register a root that owns local partition trees below one or more
137    /// exchange nodes. Unlike `register_partition_executors`, execution still
138    /// starts from a single root, while the recorded count remains available
139    /// to profile and explain consumers.
140    pub fn register_partitioned_root(
141        &mut self,
142        partition_count: usize,
143        mut executor: StreamingExecutor,
144    ) -> Result<(), QueryError> {
145        if partition_count == 0 {
146            return Err(QueryError::execution(
147                "Partitioned root requires at least one partition".to_string(),
148            ));
149        }
150        if let Some(ref runtime) = self.runtime {
151            executor.set_runtime(Some(runtime.clone()));
152        }
153        self.root_executor = Some(Box::new(executor));
154        self.partition_executors.clear();
155        self.partition_count = partition_count;
156        self.ensure_partition_arenas();
157        self.configure_registered_root_parallelism();
158        Ok(())
159    }
160
161    /// Register partition executors (one per partition).
162    ///
163    /// When set, [`execute`] will run each partition's executor sequentially
164    /// and combine the results.  This replaces any single root executor.
165    /// If a runtime is already attached, it is recursively injected into
166    /// each partition executor.
167    pub fn register_partition_executors(&mut self, executors: Vec<StreamingExecutor>) {
168        if let Some(ref rt) = self.runtime {
169            let mut injected = executors;
170            for (partition_id, ex) in injected.iter_mut().enumerate() {
171                ex.set_partition_id(partition_id);
172                ex.set_runtime(Some(rt.clone()));
173            }
174            self.partition_executors = injected;
175        } else {
176            let mut partitioned = executors;
177            for (partition_id, executor) in partitioned.iter_mut().enumerate() {
178                executor.set_partition_id(partition_id);
179            }
180            self.partition_executors = partitioned;
181        }
182        self.root_executor = None;
183        self.partition_count = self.partition_executors.len();
184        self.ensure_partition_arenas();
185    }
186
187    /// Build and register a partitioned executor tree.
188    ///
189    /// Wraps `local_trees` (one per partition) in a [`GatherOperator`] with
190    /// the given `gather_mode`, then optionally attaches a `global_tree` on
191    /// top.  The resulting tree is set as the root executor so that normal
192    /// [`execute`] and [`into_stream`] paths work unchanged.
193    ///
194    /// When `global_tree` is `Some`, the tree is:
195    ///   global_tree → Gather(local[0], local[1], ...)
196    ///
197    /// When `global_tree` is `None`, only the gather is used as root.
198    pub fn build_partitioned_executor(
199        &mut self,
200        mut local_trees: Vec<StreamingExecutor>,
201        gather_mode: GatherOperator,
202        global_tree: Option<StreamingExecutor>,
203    ) -> Result<(), QueryError> {
204        if local_trees.is_empty() {
205            return Err(QueryError::execution(
206                "Partitioned execution requires at least one local tree".to_string(),
207            ));
208        }
209        let partition_count = local_trees.len();
210        let output_layout = Arc::clone(&local_trees[0].base().output_layout);
211        for (partition_id, tree) in local_trees.iter_mut().enumerate() {
212            tree.set_partition_id(partition_id);
213        }
214        let gather_id = self.synthetic_id_alloc.allocate();
215        let mut gather = StreamingExecutor::Gather(
216            OperatorBase::new(gather_id)
217                .with_global(true)
218                .with_output_layout(output_layout),
219            local_trees,
220            gather_mode,
221        );
222
223        if let Some(ref rt) = self.runtime {
224            gather.set_runtime(Some(rt.clone()));
225        }
226
227        if let Some(mut global) = global_tree {
228            global.set_global();
229            if let Some(ref rt) = self.runtime {
230                global.set_runtime(Some(rt.clone()));
231            }
232            Self::attach_gather_as_child(&mut global, gather)?;
233            self.root_executor = Some(Box::new(global));
234        } else {
235            self.root_executor = Some(Box::new(gather));
236        }
237        self.partition_executors.clear();
238        self.partition_count = partition_count;
239        self.ensure_partition_arenas();
240        self.configure_registered_root_parallelism();
241        Ok(())
242    }
243
244    /// Build the only supported partitioned sort shape:
245    /// `LocalSort(spec) × N -> Gather::MergeSort(spec, limit)`.
246    ///
247    /// This entry point prevents callers from feeding arbitrary, unsorted
248    /// local streams to the k-way merge implementation. `limit`, when set,
249    /// is applied globally after merge order is established.
250    pub fn build_partitioned_sort_executor(
251        &mut self,
252        local_trees: Vec<StreamingExecutor>,
253        sort_expressions: Vec<Expression>,
254        sort_directions: Vec<SortDirection>,
255        limit: Option<usize>,
256    ) -> Result<(), QueryError> {
257        if sort_expressions.len() != sort_directions.len() {
258            return Err(QueryError::execution(
259                "Partitioned sort requires one direction per sort expression".to_string(),
260            ));
261        }
262        let budget = self
263            .runtime
264            .as_ref()
265            .map(|runtime| runtime.memory_budget.clone())
266            .unwrap_or_else(MemoryBudget::default_budget);
267        let local_sort_id = self.synthetic_id_alloc.allocate();
268        let local_sorts = local_trees
269            .into_iter()
270            .map(|input| {
271                let output_layout = Arc::clone(&input.base().output_layout);
272                StreamingExecutor::Blocking(
273                    OperatorBase::new(local_sort_id).with_output_layout(output_layout.clone()),
274                    Box::new(input),
275                    BlockingOperator::new(
276                        BlockingOperatorKind::Sort {
277                            sort_expressions: sort_expressions.clone(),
278                            sort_directions: sort_directions.clone(),
279                            memory_tracker: MemoryTracker::new(budget.clone()),
280                            state: None,
281                        },
282                        output_layout,
283                    ),
284                )
285            })
286            .collect::<Vec<_>>();
287        let gather_layout = local_sorts
288            .first()
289            .map(|tree| Arc::clone(&tree.base().output_layout))
290            .ok_or_else(|| {
291                QueryError::execution(
292                    "Partitioned sort requires at least one local tree".to_string(),
293                )
294            })?;
295        self.build_partitioned_executor(
296            local_sorts,
297            GatherOperator::merge_sort(sort_expressions, sort_directions, limit, gather_layout),
298            None,
299        )
300    }
301
302    /// Build a semantically-correct partitioned join shape:
303    /// `Gather(left local trees) -> GlobalJoin <- Gather(right local trees)`.
304    ///
305    /// This is a gather exchange rather than a hash/range shuffle. It keeps
306    /// every join variant correct while the engine remains single-threaded;
307    /// a future distributed exchange may replace either gather independently.
308    pub fn build_partitioned_join_executor(
309        &mut self,
310        mut left_local_trees: Vec<StreamingExecutor>,
311        mut right_local_trees: Vec<StreamingExecutor>,
312        mut global_join: StreamingExecutor,
313    ) -> Result<(), QueryError> {
314        if left_local_trees.is_empty() || right_local_trees.is_empty() {
315            return Err(QueryError::execution(
316                "Partitioned join requires at least one partition on both inputs".to_string(),
317            ));
318        }
319        if left_local_trees.len() != right_local_trees.len() {
320            return Err(QueryError::execution(format!(
321                "Partitioned join input partition counts differ: left={}, right={}",
322                left_local_trees.len(),
323                right_local_trees.len()
324            )));
325        }
326
327        let partition_count = left_local_trees.len();
328        let left_layout = Arc::clone(&left_local_trees[0].base().output_layout);
329        let right_layout = Arc::clone(&right_local_trees[0].base().output_layout);
330        for (partition_id, tree) in left_local_trees.iter_mut().enumerate() {
331            tree.set_partition_id(partition_id);
332        }
333        for (partition_id, tree) in right_local_trees.iter_mut().enumerate() {
334            tree.set_partition_id(partition_id);
335        }
336
337        let left_gather_id = self.synthetic_id_alloc.allocate();
338        let right_gather_id = self.synthetic_id_alloc.allocate();
339        let mut left_gather = StreamingExecutor::Gather(
340            OperatorBase::new(left_gather_id)
341                .with_global(true)
342                .with_output_layout(left_layout.clone()),
343            left_local_trees,
344            GatherOperator::concatenate(left_layout),
345        );
346        let mut right_gather = StreamingExecutor::Gather(
347            OperatorBase::new(right_gather_id)
348                .with_global(true)
349                .with_output_layout(right_layout.clone()),
350            right_local_trees,
351            GatherOperator::concatenate(right_layout),
352        );
353        if let Some(ref runtime) = self.runtime {
354            left_gather.set_runtime(Some(runtime.clone()));
355            right_gather.set_runtime(Some(runtime.clone()));
356        }
357
358        global_join.set_global();
359        if let Some(ref runtime) = self.runtime {
360            global_join.set_runtime(Some(runtime.clone()));
361        }
362        Self::attach_gathers_as_join_children(&mut global_join, left_gather, right_gather)?;
363
364        self.root_executor = Some(Box::new(global_join));
365        self.partition_executors.clear();
366        self.partition_count = partition_count;
367        self.ensure_partition_arenas();
368        self.configure_registered_root_parallelism();
369        Ok(())
370    }
371
372    /// Replace the single input child of a root executor with a gather node.
373    fn attach_gather_as_child(
374        root: &mut StreamingExecutor,
375        gather: StreamingExecutor,
376    ) -> Result<(), QueryError> {
377        match root {
378            StreamingExecutor::Unary(_, input, _)
379            | StreamingExecutor::Blocking(_, input, _)
380            | StreamingExecutor::Graph(_, input, _)
381            | StreamingExecutor::RecursiveFragment(_, input, _)
382            | StreamingExecutor::Sink(_, input, _)
383            | StreamingExecutor::Ddl(_, input, _)
384            | StreamingExecutor::Fulltext(_, input, _)
385            | StreamingExecutor::Vector(_, input, _)
386            | StreamingExecutor::Txn(_, input, _) => {
387                **input = gather;
388                Ok(())
389            }
390            StreamingExecutor::Join(_, left, _, _) => {
391                **left = gather;
392                Ok(())
393            }
394            _ => Err(QueryError::execution(
395                "The requested global executor cannot accept a Gather input".to_string(),
396            )),
397        }
398    }
399
400    fn attach_gathers_as_join_children(
401        root: &mut StreamingExecutor,
402        left_gather: StreamingExecutor,
403        right_gather: StreamingExecutor,
404    ) -> Result<(), QueryError> {
405        match root {
406            StreamingExecutor::Join(_, left, right, _) => {
407                **left = left_gather;
408                **right = right_gather;
409                Ok(())
410            }
411            _ => Err(QueryError::execution(
412                "Partitioned join requires a Join executor as its global root".to_string(),
413            )),
414        }
415    }
416
417    /// Returns the number of registered partition executors.
418    pub fn partition_count(&self) -> usize {
419        self.partition_count
420    }
421
422    /// Ensure runtime state_arenas match the engine's partition count.
423    /// Best-effort: only succeeds when the Arc is unique at call time.
424    fn ensure_partition_arenas(&mut self) {
425        if let Some(arc) = self.runtime.as_mut() {
426            if let Some(rt) = Arc::get_mut(arc) {
427                rt.set_partition_count(self.partition_count + 1);
428            }
429        }
430    }
431
432    /// Attach an execution runtime (for cancellation, profiling, memory tracking).
433    /// Also propagates the runtime recursively into all operators.
434    /// If `max_workers > 1` and no shared scheduler is configured, creates
435    /// a per-query `MorselWorkerPool`.
436    pub fn set_runtime(&mut self, runtime: Arc<ExecutionRuntime>) {
437        if let Some(ref mut executor) = self.root_executor {
438            executor.set_runtime(Some(runtime.clone()));
439        }
440        for executor in &mut self.partition_executors {
441            executor.set_runtime(Some(runtime.clone()));
442        }
443        if self.max_workers > 1 && runtime.get_shared_scheduler().is_none() {
444            let pool = MorselWorkerPool::new(self.max_workers);
445            runtime.set_worker_pool(Some(pool));
446        } else if self.max_workers == 1 && runtime.get_shared_scheduler().is_none() {
447            runtime.set_worker_pool(None);
448        }
449        runtime.set_max_buffered_chunks(self.max_buffered_chunks);
450        if runtime.get_spill_manager().is_none() {
451            let qid = runtime.query_id().query_id;
452            if let Ok(manager) = SpillManager::new(self.spill_config.clone(), qid) {
453                runtime.set_spill_manager(Some(Arc::new(manager)));
454            }
455        }
456        self.runtime = Some(runtime);
457        // Safety net: ensure arenas match partition count if the Arc
458        // is still unique at this point (e.g. when called before any clones).
459        self.ensure_partition_arenas();
460    }
461
462    fn configure_registered_root_parallelism(&mut self) {
463        // No longer needed — parallel config is stored on the runtime.
464        // Kept as a hook for future per-operator config.
465    }
466
467    /// Return a reference to the attached runtime, if any.
468    pub fn runtime(&self) -> Option<&Arc<ExecutionRuntime>> {
469        self.runtime.as_ref()
470    }
471
472    /// Open the root executor (used by [`ResultStream`]).
473    pub fn open_root(&mut self) -> Result<(), QueryError> {
474        let executor = self
475            .root_executor
476            .as_mut()
477            .ok_or_else(|| QueryError::execution("No executor registered".to_string()))?;
478        executor.open()
479    }
480
481    /// Pull the next chunk from the root executor (used by [`ResultStream`]).
482    pub fn next_chunk_from_root(&mut self) -> Result<Option<DataChunk>, QueryError> {
483        let executor = self
484            .root_executor
485            .as_mut()
486            .ok_or_else(|| QueryError::execution("No executor registered".to_string()))?;
487        let mut chunk = executor.advance()?;
488        // Every chunk leaving the executor tree is physical: expand any
489        // symbolic multiplicity and materialize any propagated selection.
490        // Chunk-at-a-time API consumers (server/CLI pagination) read rows
491        // directly and do not honor `multiplicity`, so it must not escape.
492        if let Some(chunk) = chunk.as_mut() {
493            chunk.normalize_for_opaque("Root");
494        }
495        Ok(chunk)
496    }
497
498    /// Stop the root executor (signal upstream to stop producing).
499    ///
500    /// Used by [`ResultStream`] before close to allow operators to
501    /// stop upstream production early.
502    pub fn stop_root(&mut self) -> Result<(), QueryError> {
503        let mut first_error = None;
504        if let Some(ref mut executor) = self.root_executor {
505            if let Err(error) = executor.stop_tree() {
506                first_error = Some(error);
507            }
508        }
509        for executor in &mut self.partition_executors {
510            if let Err(error) = executor.stop_tree() {
511                if first_error.is_none() {
512                    first_error = Some(error);
513                }
514            }
515        }
516        first_error.map_or(Ok(()), Err)
517    }
518
519    /// Close the root executor (used by [`ResultStream`]).
520    pub fn close_root(&mut self) -> Result<(), QueryError> {
521        let result = if let Some(ref mut executor) = self.root_executor {
522            executor.close_tree()
523        } else {
524            Ok(())
525        };
526        if let Some(ref runtime) = self.runtime {
527            runtime.release_resources();
528            runtime.reset_arena();
529        }
530        result
531    }
532
533    /// Execute the query and return a streaming result handle.
534    ///
535    /// This is the default execution path. Results are delivered chunk-at-a-time
536    /// via [`ResultStream`], enabling:
537    /// - Low first-chunk latency (no need to wait for full materialization)
538    /// - Constant memory usage regardless of result size
539    /// - Cooperative cancellation mid-stream
540    ///
541    /// For cases that require full materialization (e.g. EXPLAIN/PROFILE
542    /// diagnostics, test assertions), use [`execute_collected`](Self::execute_collected).
543    ///
544    /// # Partitioned Execution
545    /// When partition executors are registered, partitions are executed
546    /// sequentially and the collected chunks are wrapped in a stream.
547    /// Formal parallelism runs through the Gather-based root.
548    pub fn execute(mut self) -> Result<ResultStream, QueryError> {
549        if !self.partition_executors.is_empty() {
550            let chunks = self.execute_collected()?;
551            let runtime = self
552                .runtime
553                .take()
554                .ok_or_else(|| QueryError::execution("No ExecutionRuntime attached".to_string()))?;
555            runtime.profile_start();
556            return Ok(ResultStream::from_collected(chunks, self, runtime));
557        }
558        self.into_stream()
559    }
560
561    /// Execute and collect all chunks into memory (explicit materialization).
562    ///
563    /// Use this when you need the full result set upfront. For streaming
564    /// consumption, prefer [`execute`](Self::execute).
565    pub fn execute_collected(&mut self) -> Result<Vec<DataChunk>, QueryError> {
566        let profile_started = self.runtime.is_some();
567        if profile_started {
568            self.runtime.as_ref().unwrap().profile_start();
569        }
570
571        let result = if !self.partition_executors.is_empty() {
572            self.execute_partitions()
573        } else {
574            self.execute_single()
575        };
576
577        // On failure, close any executors that are still open.
578        if result.is_err() {
579            self.close_open_executors();
580        }
581
582        // Always end profile and release resources (success or failure).
583        if profile_started {
584            if let Some(ref rt) = self.runtime {
585                rt.profile_end();
586                rt.release_resources();
587                rt.reset_arena();
588            }
589        }
590
591        // Extract peak memory only on success.
592        if result.is_ok() {
593            for executor in self
594                .partition_executors
595                .iter()
596                .chain(self.root_executor.iter().map(|e| e.as_ref()))
597            {
598                let peak = extract_peak_memory(executor);
599                if peak > 0 {
600                    executor.record_profile_peak_memory(peak);
601                }
602            }
603        }
604
605        result
606    }
607
608    /// Close any executors whose `open()` succeeded but whose `close()`
609    /// was not called due to a mid-execution error.
610    fn close_open_executors(&mut self) {
611        if let Some(ref mut executor) = self.root_executor {
612            let _ = executor.close_tree();
613        }
614        for executor in &mut self.partition_executors {
615            let _ = executor.close_tree();
616        }
617    }
618
619    /// Execute the uncomposed partition list sequentially.
620    ///
621    /// Formal parallelism is intentionally attached to the Gather-based
622    /// partitioned root. This compatibility helper has no Gather semantics
623    /// and therefore remains serial.
624    ///
625    /// if the main execution loop succeeds but close fails, the close
626    /// error is returned instead of being silently logged.
627    fn execute_partitions(&mut self) -> Result<Vec<DataChunk>, QueryError> {
628        let mut all_chunks = Vec::new();
629        for executor in &mut self.partition_executors {
630            executor.open()?;
631            let loop_result = (|| -> Result<(), QueryError> {
632                while let Some(mut chunk) = executor.advance()? {
633                    // Tree exit: chunks must be physical (see `next_chunk_from_root`).
634                    chunk.normalize_for_opaque("Engine");
635                    all_chunks.push(chunk);
636                }
637                Ok(())
638            })();
639            let close_err = executor.close_tree().err();
640            // propagate close error when main loop succeeded.
641            loop_result?;
642            if let Some(e) = close_err {
643                return Err(e);
644            }
645        }
646        Ok(all_chunks)
647    }
648
649    /// Execute a single root executor.
650    ///
651    /// if the main execution loop succeeds but close fails, the close
652    /// error is returned instead of being silently logged.
653    fn execute_single(&mut self) -> Result<Vec<DataChunk>, QueryError> {
654        let mut output_chunks = Vec::new();
655
656        let executor = self
657            .root_executor
658            .as_mut()
659            .ok_or_else(|| QueryError::execution("No executor registered".to_string()))?;
660
661        executor.open()?;
662        let loop_result = (|| -> Result<(), QueryError> {
663            while let Some(mut chunk) = executor.advance()? {
664                // Tree exit: chunks must be physical (see `next_chunk_from_root`).
665                chunk.normalize_for_opaque("Engine");
666                output_chunks.push(chunk);
667            }
668            Ok(())
669        })();
670        let close_err = executor.close_tree().err();
671        // propagate close error when main loop succeeded.
672        loop_result?;
673        if let Some(e) = close_err {
674            return Err(e);
675        }
676
677        Ok(output_chunks)
678    }
679
680    /// Convert this engine into a [`ResultStream`] for chunk-at-a-time consumption.
681    ///
682    /// Requires a runtime to have been set (via [`set_runtime`]).
683    /// Returns an error if no runtime has been attached.
684    pub fn into_stream(mut self) -> Result<ResultStream, QueryError> {
685        let runtime = self
686            .runtime
687            .take()
688            .ok_or_else(|| QueryError::execution("No ExecutionRuntime attached".to_string()))?;
689        runtime.profile_start();
690        Ok(ResultStream::new(self, runtime))
691    }
692}
693
694/// Extract peak memory from a root executor.
695fn extract_peak_memory(executor: &StreamingExecutor) -> u64 {
696    executor.peak_memory_bytes()
697}
698
699impl Default for StreamingExecutionEngine {
700    fn default() -> Self {
701        Self::new()
702    }
703}
704
705#[cfg(test)]
706mod tests;