Skip to main content

linkrs_query/executor/streaming/
stream_result.rs

1use std::sync::{Arc, Weak};
2
3use parking_lot::Mutex;
4
5use super::chunk::{ColumnInfo, DataChunk, LocalChunkCollector, Schema};
6use super::runtime::ExecutionRuntime;
7use super::stream::ResultStream;
8use crate::executor::base::ExecutionResult;
9use linkrs_core::error::QueryError;
10use linkrs_core::DataSet;
11
12/// Thread-safe (Send + Sync) streaming result handle.
13///
14/// Wraps a [`ResultStream`] or pre-materialized [`ExecutionResult`] behind an
15/// `Arc<Mutex<>>` so it can be shared across async tasks or API boundaries.
16use std::sync::atomic::{AtomicBool, Ordering};
17
18type DropCallback = Arc<Mutex<Option<Box<dyn FnOnce() + Send>>>>;
19type TransactionCallback = Box<dyn FnOnce() -> Result<(), String> + Send>;
20type TransactionFinalizer = Arc<Mutex<Option<(TransactionCallback, TransactionCallback)>>>;
21
22#[derive(Clone)]
23pub struct StreamingQueryResult {
24    inner: Arc<Mutex<StreamState>>,
25    runtime: Arc<ExecutionRuntime>,
26    on_drop: DropCallback,
27    transaction_finalizer: TransactionFinalizer,
28    dropped: Arc<AtomicBool>,
29}
30
31impl Drop for StreamingQueryResult {
32    fn drop(&mut self) {
33        // A streaming result is routinely cloned between the API task and the
34        // blocking producer. Deregistration must happen only after the final
35        // handle is gone; otherwise KILL QUERY can no longer find active work.
36        if Arc::strong_count(&self.inner) != 1 {
37            return;
38        }
39        if self.dropped.swap(true, Ordering::Relaxed) {
40            return;
41        }
42        if let Err(error) = self.finalize_transaction() {
43            log::error!("Streaming transaction finalization failed: {}", error);
44        }
45        if let Some(f) = self.on_drop.lock().take() {
46            f();
47        }
48    }
49}
50
51enum StreamState {
52    /// Active streaming from an executor pipeline.
53    /// The `Option<Vec<String>>` caches column names once the first chunk is
54    /// received, so that callers can inspect the schema even after the stream
55    /// is exhausted (provided at least one chunk was produced).
56    Streaming(ResultStream, Option<Vec<String>>),
57    /// Pre-materialized result (EXPLAIN/PROFILE/SpaceSwitched).
58    Materialized {
59        rows: Vec<Vec<linkrs_core::Value>>,
60        col_names: Vec<String>,
61        exhausted: bool,
62    },
63    /// Stream is exhausted.
64    Exhausted,
65    /// Stream was explicitly closed before normal exhaustion.
66    Closed,
67}
68
69impl StreamingQueryResult {
70    /// Wrap a [`ResultStream`] into a thread-safe handle.
71    pub fn new(stream: ResultStream, runtime: Arc<ExecutionRuntime>) -> Self {
72        Self::new_with_schema(stream, runtime, Vec::new())
73    }
74
75    /// Wrap a stream with the schema fixed by the physical output contract.
76    /// This keeps empty results observable without waiting for a first chunk.
77    pub fn new_with_schema(
78        stream: ResultStream,
79        runtime: Arc<ExecutionRuntime>,
80        col_names: Vec<String>,
81    ) -> Self {
82        Self {
83            inner: Arc::new(Mutex::new(StreamState::Streaming(stream, Some(col_names)))),
84            runtime,
85            on_drop: Arc::new(Mutex::new(None)),
86            transaction_finalizer: Arc::new(Mutex::new(None)),
87            dropped: Arc::new(AtomicBool::new(false)),
88        }
89    }
90
91    /// Create a pre-materialized result from an [`ExecutionResult`].
92    ///
93    /// The result is returned as a single chunk on the first `next_chunk()` call.
94    /// Useful for EXPLAIN/PROFILE results that are already materialized.
95    pub fn from_execution_result(result: ExecutionResult) -> Self {
96        match result {
97            ExecutionResult::DataSet { data: ds, .. } => {
98                let exhausted = ds.rows.is_empty();
99                let runtime = Arc::new(ExecutionRuntime::default_budget());
100                Self {
101                    inner: Arc::new(Mutex::new(StreamState::Materialized {
102                        rows: ds.rows,
103                        col_names: ds.col_names,
104                        exhausted,
105                    })),
106                    runtime,
107                    on_drop: Arc::new(Mutex::new(None)),
108                    transaction_finalizer: Arc::new(Mutex::new(None)),
109                    dropped: Arc::new(AtomicBool::new(false)),
110                }
111            }
112            ExecutionResult::Success | ExecutionResult::Empty => {
113                let runtime = Arc::new(ExecutionRuntime::default_budget());
114                Self {
115                    inner: Arc::new(Mutex::new(StreamState::Exhausted)),
116                    runtime,
117                    on_drop: Arc::new(Mutex::new(None)),
118                    transaction_finalizer: Arc::new(Mutex::new(None)),
119                    dropped: Arc::new(AtomicBool::new(false)),
120                }
121            }
122            ExecutionResult::SpaceSwitched(summary) => {
123                let row = vec![
124                    linkrs_core::Value::string(summary.name.clone()),
125                    linkrs_core::Value::BigInt(summary.id as i64),
126                ];
127                let col_names = vec!["space_name".to_string(), "space_id".to_string()];
128                let runtime = Arc::new(ExecutionRuntime::default_budget());
129                Self {
130                    inner: Arc::new(Mutex::new(StreamState::Materialized {
131                        rows: vec![row],
132                        col_names,
133                        exhausted: false,
134                    })),
135                    runtime,
136                    on_drop: Arc::new(Mutex::new(None)),
137                    transaction_finalizer: Arc::new(Mutex::new(None)),
138                    dropped: Arc::new(AtomicBool::new(false)),
139                }
140            }
141            ExecutionResult::Error(msg) => {
142                let col_names = vec!["error".to_string()];
143                let runtime = Arc::new(ExecutionRuntime::default_budget());
144                Self {
145                    inner: Arc::new(Mutex::new(StreamState::Materialized {
146                        rows: vec![vec![linkrs_core::Value::string(msg)]],
147                        col_names,
148                        exhausted: false,
149                    })),
150                    runtime,
151                    on_drop: Arc::new(Mutex::new(None)),
152                    transaction_finalizer: Arc::new(Mutex::new(None)),
153                    dropped: Arc::new(AtomicBool::new(false)),
154                }
155            }
156            ExecutionResult::ConfigUpdate {
157                module,
158                name,
159                value,
160            } => {
161                let row = vec![
162                    module
163                        .map(linkrs_core::Value::string)
164                        .unwrap_or(linkrs_core::Value::Null(linkrs_core::NullType::Null)),
165                    linkrs_core::Value::string(name),
166                    value,
167                ];
168                let col_names = vec![
169                    "module".to_string(),
170                    "config_name".to_string(),
171                    "config_value".to_string(),
172                ];
173                let runtime = Arc::new(ExecutionRuntime::default_budget());
174                Self {
175                    inner: Arc::new(Mutex::new(StreamState::Materialized {
176                        rows: vec![row],
177                        col_names,
178                        exhausted: false,
179                    })),
180                    runtime,
181                    on_drop: Arc::new(Mutex::new(None)),
182                    transaction_finalizer: Arc::new(Mutex::new(None)),
183                    dropped: Arc::new(AtomicBool::new(false)),
184                }
185            }
186            ExecutionResult::ShowConfigs { module } => {
187                let row = vec![module
188                    .map(linkrs_core::Value::string)
189                    .unwrap_or(linkrs_core::Value::Null(linkrs_core::NullType::Null))];
190                let col_names = vec!["module".to_string()];
191                let runtime = Arc::new(ExecutionRuntime::default_budget());
192                Self {
193                    inner: Arc::new(Mutex::new(StreamState::Materialized {
194                        rows: vec![row],
195                        col_names,
196                        exhausted: false,
197                    })),
198                    runtime,
199                    on_drop: Arc::new(Mutex::new(None)),
200                    transaction_finalizer: Arc::new(Mutex::new(None)),
201                    dropped: Arc::new(AtomicBool::new(false)),
202                }
203            }
204        }
205    }
206
207    /// Pull the next chunk of results.
208    ///
209    /// Returns `Ok(None)` when the stream is exhausted.
210    /// Returns an error if the query has been cancelled.
211    pub fn next_chunk(&self) -> Result<Option<DataChunk>, QueryError> {
212        let mut guard = self.inner.lock();
213        match &mut *guard {
214            StreamState::Streaming(ref mut stream, ref mut cached) => {
215                let result = match stream.next_chunk() {
216                    Ok(result) => result,
217                    Err(error) => {
218                        *guard = StreamState::Closed;
219                        drop(guard);
220                        self.finalize_transaction().map_err(QueryError::execution)?;
221                        return Err(error);
222                    }
223                };
224                if let Some(ref chunk) = result {
225                    if cached.is_none() {
226                        *cached = Some(chunk.col_names());
227                    }
228                }
229                let exhausted = result.is_none();
230                if exhausted {
231                    *guard = StreamState::Exhausted;
232                }
233                drop(guard);
234                if exhausted {
235                    self.finalize_transaction().map_err(QueryError::execution)?;
236                }
237                Ok(result)
238            }
239            StreamState::Materialized {
240                rows,
241                col_names,
242                exhausted,
243            } => {
244                if *exhausted {
245                    *guard = StreamState::Exhausted;
246                    drop(guard);
247                    self.finalize_transaction().map_err(QueryError::execution)?;
248                    return Ok(None);
249                }
250                *exhausted = true;
251                let names = std::mem::take(col_names);
252                let data = std::mem::take(rows);
253                let columns: Vec<ColumnInfo> = names
254                    .iter()
255                    .map(|n| ColumnInfo {
256                        name: n.clone(),
257                        data_type: String::new(),
258                    })
259                    .collect();
260                let chunk = DataChunk::new(data, Arc::new(Schema::new(columns)));
261                Ok(Some(chunk))
262            }
263            StreamState::Exhausted | StreamState::Closed => Ok(None),
264        }
265    }
266
267    /// Cancel the query execution.
268    pub fn cancel(&self) {
269        self.runtime.cancel();
270        if let Err(error) = self.abort_transaction() {
271            log::error!("Streaming transaction cancellation failed: {}", error);
272        }
273    }
274
275    /// Check whether the query has been cancelled.
276    pub fn is_cancelled(&self) -> bool {
277        self.runtime.is_cancelled()
278    }
279
280    /// Close the stream and release resources.
281    pub fn close(&self) -> Result<(), QueryError> {
282        let mut guard = self.inner.lock();
283        let result = match &mut *guard {
284            StreamState::Streaming(ref mut stream, _) => stream.close(),
285            _ => Ok(()),
286        };
287        *guard = StreamState::Closed;
288        drop(guard);
289        result?;
290        self.abort_transaction().map_err(QueryError::execution)
291    }
292
293    /// Consume all remaining chunks and materialise into a `DataSet`.
294    ///
295    /// Routes through `LocalChunkCollector`, the single terminal collection
296    /// logic shared with `convert_chunks_to_dataset` and `ResultStream::collect`.
297    pub fn collect(&self) -> Result<DataSet, QueryError> {
298        let spill_manager = self.runtime.get_spill_manager();
299        let mut collector: Option<LocalChunkCollector> = None;
300
301        while let Some(mut chunk) = self.next_chunk()? {
302            let collector = match collector {
303                Some(ref mut c) => c,
304                None => {
305                    let mut fresh = LocalChunkCollector::new(chunk.col_names());
306                    if let Some(ref manager) = spill_manager {
307                        fresh.attach_spill_manager(manager.clone());
308                    }
309                    collector.insert(fresh)
310                }
311            };
312            collector.push_chunk(&mut chunk)?;
313        }
314
315        let Some(mut collector) = collector else {
316            return Ok(DataSet::with_columns(Vec::new()));
317        };
318        collector.finish_spill()?;
319        let stats = self.runtime.columnar_stats();
320        let spilled_rows = collector.spilled_rows();
321        let spilled_bytes = collector.spilled_bytes();
322        let spilled_runs = collector.spilled_run_count() as u64;
323        let (all_rows, col_names) = collector.into_rows()?;
324        if spilled_rows > 0 {
325            stats.record_spill_with_runs(spilled_rows, spilled_bytes, spilled_runs);
326        }
327        Ok(DataSet::from_rows(all_rows, col_names))
328    }
329
330    /// Return column names if known.
331    ///
332    /// For materialized results, column names are available immediately.
333    /// For streaming results, they are available after the first chunk is
334    /// pulled (or `None` if no chunk was ever produced).
335    pub fn column_names(&self) -> Option<Vec<String>> {
336        let guard = self.inner.lock();
337        match &*guard {
338            StreamState::Streaming(_, cached) => cached.clone(),
339            StreamState::Materialized { col_names, .. } => Some(col_names.clone()),
340            StreamState::Exhausted | StreamState::Closed => None,
341        }
342    }
343
344    /// Access the underlying execution runtime (for profiling, cancel, etc.)
345    pub fn runtime(&self) -> &ExecutionRuntime {
346        &self.runtime
347    }
348
349    /// Return a [`Weak`] reference to the execution runtime for KILL QUERY registration.
350    pub fn runtime_downgrade(&self) -> Weak<ExecutionRuntime> {
351        Arc::downgrade(&self.runtime)
352    }
353
354    /// Register a cleanup callback that fires when this handle is dropped.
355    ///
356    /// Used by the API layer to deregister the query from the session
357    /// when the stream ends (via either completion, error, or client disconnect).
358    pub fn set_on_drop(&self, f: Box<dyn FnOnce() + Send>) {
359        let previous = self.on_drop.lock().take();
360        *self.on_drop.lock() = Some(Box::new(move || {
361            if let Some(previous) = previous {
362                previous();
363            }
364            f();
365        }));
366    }
367
368    /// Register a transaction finalizer that fires when the stream is dropped.
369    ///
370    /// - `commit`: called when the stream is fully consumed without error.
371    /// - `abort`: called on error, cancellation, or premature drop.
372    ///
373    /// Only one finalizer can be registered. The `on_drop` callback is used
374    /// internally to invoke the appropriate branch.
375    pub fn set_transaction_finalizer(
376        &self,
377        commit: Box<dyn FnOnce() + Send>,
378        abort: Box<dyn FnOnce() + Send>,
379    ) {
380        self.set_transaction_finalizer_with_result(
381            Box::new(move || {
382                commit();
383                Ok(())
384            }),
385            Box::new(move || {
386                abort();
387                Ok(())
388            }),
389        );
390    }
391
392    /// Register a finalizer whose error is returned at stream exhaustion.
393    /// A finalizer triggered by `Drop` logs the error because no result can be
394    /// returned after ownership has been released.
395    pub fn set_transaction_finalizer_with_result(
396        &self,
397        commit: TransactionCallback,
398        abort: TransactionCallback,
399    ) {
400        *self.transaction_finalizer.lock() = Some((commit, abort));
401    }
402
403    fn finalize_transaction(&self) -> Result<(), String> {
404        let is_exhausted = matches!(*self.inner.lock(), StreamState::Exhausted);
405        let Some((commit, abort)) = self.transaction_finalizer.lock().take() else {
406            return Ok(());
407        };
408        if is_exhausted {
409            commit()
410        } else {
411            abort()
412        }
413    }
414
415    fn abort_transaction(&self) -> Result<(), String> {
416        let Some((_, abort)) = self.transaction_finalizer.lock().take() else {
417            return Ok(());
418        };
419        abort()
420    }
421
422    /// Set fallback column names that are available even before the first
423    /// chunk is pulled (or when the result set is empty).
424    ///
425    /// Only takes effect when no chunk has been received yet (i.e. the
426    /// cached column names are `None`).  Once a chunk arrives its column
427    /// names take precedence and the fallback is ignored.
428    pub fn set_fallback_column_names(&self, names: Vec<String>) {
429        let mut guard = self.inner.lock();
430        if let StreamState::Streaming(_, ref mut cached) = &mut *guard {
431            if cached.is_none() {
432                *cached = Some(names);
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::executor::base::MemoryBudget;
442    use crate::executor::streaming::engine::StreamingExecutionEngine;
443    use crate::executor::streaming::executor::StreamingExecutor;
444    use crate::executor::streaming::operators::base::OperatorBase;
445    use crate::executor::streaming::operators::source_operator::SourceOperator;
446    use crate::executor::streaming::operators::source_operator::SourceOperatorKind;
447    use crate::executor::streaming::runtime::QueryIdentity;
448    use crate::executor::streaming::slot::SlotLayout;
449    use linkrs_core::Value;
450
451    fn create_test_stream(count: usize) -> StreamingQueryResult {
452        let mut engine = StreamingExecutionEngine::new();
453        let runtime = Arc::new(ExecutionRuntime::new(
454            QueryIdentity::default(),
455            MemoryBudget::default_budget(),
456            None,
457            crate::executor::base::SearchContext::default(),
458        ));
459        engine.set_runtime(runtime.clone());
460
461        let buffer: Vec<Vec<Value>> = (0..count).map(|i| vec![Value::BigInt(i as i64)]).collect();
462
463        let scan = StreamingExecutor::Source(
464            OperatorBase::new(0),
465            SourceOperator::new(
466                SourceOperatorKind::ScanVertices {
467                    buffer,
468                    current_index: 0,
469                    col_names: vec!["id".to_string()],
470                },
471                Arc::new(SlotLayout::from_names(&["id".to_string()])),
472            ),
473        );
474        engine.register_executor(0, scan);
475        let stream = engine.into_stream().unwrap();
476        StreamingQueryResult::new(stream, runtime)
477    }
478
479    #[test]
480    fn test_next_chunk() {
481        let result = create_test_stream(10);
482        let chunk = result.next_chunk().unwrap();
483        assert!(chunk.is_some());
484        assert_eq!(chunk.unwrap().len(), 10);
485        let done = result.next_chunk().unwrap();
486        assert!(done.is_none());
487    }
488
489    #[test]
490    fn test_cancel() {
491        let result = create_test_stream(100);
492        result.cancel();
493        assert!(result.is_cancelled());
494        assert!(result.next_chunk().is_err());
495    }
496
497    #[test]
498    fn test_collect() {
499        let result = create_test_stream(25);
500        let ds = result.collect().unwrap();
501        assert_eq!(ds.row_count(), 25);
502    }
503
504    #[test]
505    fn test_clone_shared_stream() {
506        let result = create_test_stream(10);
507        let result2 = result.clone();
508        // Both clones share the same underlying stream via Arc<Mutex<>>.
509        // First call consumes the only chunk.
510        assert!(result.next_chunk().unwrap().is_some());
511        // Second call (via clone) sees the stream is exhausted.
512        assert!(result2.next_chunk().unwrap().is_none());
513    }
514
515    #[test]
516    fn test_from_execution_result_dataset() {
517        let ds = DataSet::from_rows(
518            vec![vec![Value::Int(1)], vec![Value::Int(2)]],
519            vec!["id".to_string()],
520        );
521        let result =
522            StreamingQueryResult::from_execution_result(ExecutionResult::DataSet { data: ds });
523        let chunk = result.next_chunk().unwrap().unwrap();
524        assert_eq!(chunk.len(), 2);
525        assert_eq!(chunk.col_names(), vec!["id"]);
526        assert!(result.next_chunk().unwrap().is_none());
527    }
528
529    #[test]
530    fn test_from_execution_result_empty() {
531        let result = StreamingQueryResult::from_execution_result(ExecutionResult::Empty);
532        assert!(result.next_chunk().unwrap().is_none());
533    }
534
535    #[test]
536    fn test_from_execution_result_error() {
537        let result =
538            StreamingQueryResult::from_execution_result(ExecutionResult::Error("oops".to_string()));
539        let chunk = result.next_chunk().unwrap().unwrap();
540        assert!(!chunk.is_empty());
541    }
542
543    // ── Resource regression tests ──
544
545    #[test]
546    fn test_on_drop_callback_fires_on_drop() {
547        let result = create_test_stream(5);
548        let fired = Arc::new(AtomicBool::new(false));
549        let fired_clone = fired.clone();
550        result.set_on_drop(Box::new(move || {
551            fired_clone.store(true, Ordering::Relaxed);
552        }));
553        drop(result);
554        assert!(
555            fired.load(Ordering::Relaxed),
556            "on_drop must fire when handle is dropped"
557        );
558    }
559
560    #[test]
561    fn test_on_drop_waits_for_the_last_clone() {
562        let result = create_test_stream(5);
563        let call_count = Arc::new(AtomicBool::new(false));
564        let count_clone = call_count.clone();
565        result.set_on_drop(Box::new(move || {
566            count_clone.store(true, Ordering::Relaxed);
567        }));
568
569        let r2 = result.clone();
570        let r3 = r2.clone();
571
572        // Intermediate handles must not deregister an active stream.
573        drop(r2);
574        assert!(
575            !call_count.load(Ordering::Relaxed),
576            "on_drop must wait for the last handle"
577        );
578
579        drop(r3);
580        assert!(
581            !call_count.load(Ordering::Relaxed),
582            "on_drop must wait for the last handle"
583        );
584
585        drop(result);
586        assert!(
587            call_count.load(Ordering::Relaxed),
588            "on_drop must fire when the final handle is dropped"
589        );
590    }
591
592    #[test]
593    fn test_on_drop_not_called_when_not_set() {
594        let result = create_test_stream(5);
595        // Must not panic when dropping without on_drop registered.
596        drop(result);
597    }
598
599    #[test]
600    fn test_cancel_after_partial_consumption() {
601        let result = create_test_stream(50);
602        let chunk = result.next_chunk().unwrap();
603        assert!(chunk.is_some());
604        // Cancel mid-stream
605        result.cancel();
606        assert!(result.is_cancelled());
607        assert!(result.next_chunk().is_err());
608    }
609
610    #[test]
611    fn test_double_cancel_is_safe() {
612        let result = create_test_stream(10);
613        result.cancel();
614        result.cancel(); // Must not panic.
615        assert!(result.is_cancelled());
616    }
617
618    #[test]
619    fn test_cancel_with_zero_rows_stream() {
620        let result = create_test_stream(0);
621        result.cancel();
622        assert!(result.is_cancelled());
623        assert!(result.next_chunk().is_err());
624    }
625
626    #[test]
627    fn test_from_execution_result_success_has_exhausted_stream() {
628        let result = StreamingQueryResult::from_execution_result(ExecutionResult::Success);
629        assert!(result.next_chunk().unwrap().is_none());
630    }
631
632    #[test]
633    fn test_drop_without_consuming_does_not_leak() {
634        // Streaming engine resources must be released on drop even
635        // when no chunk was ever pulled.
636        let result = create_test_stream(10);
637        drop(result);
638    }
639}