Skip to main content

spiral/
scan.rs

1//! Fixed scan metadata and lazy execution lanes sharing Arrow batches.
2
3use std::collections::BTreeMap;
4use std::ops::Range;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8
9use arrow_array::{RecordBatch, RecordBatchReader};
10use arrow_schema::{Schema, SchemaRef};
11use async_trait::async_trait;
12use futures::task::{ArcWake, AtomicWaker, waker_ref};
13use futures::{Stream, StreamExt as _, TryStreamExt as _, stream};
14use parking_lot::Mutex;
15use tokio::sync::mpsc;
16use tokio::task::{JoinHandle, spawn_blocking};
17use uuid::Uuid;
18
19use crate::scandal::TableBinding;
20pub use crate::scandal::api::{ColumnStatistics, ScanStatistics};
21use crate::scandal::expression::Expression;
22use crate::scandal::types::require_exact_filter;
23pub use crate::scandal::types::{
24    FilterClause, FilterSupport, ScanMetricsSnapshot, ScanOptions, ScanRowRange, StorageCounters,
25    StorageScope, TableSplit, WindowCounters,
26};
27use crate::{
28    Error, ExecutionDiagnostics, ExecutionOptions, PlanDescription, ResourceAddress, SortColumn,
29    TableIdentity, WriteLayout,
30};
31
32type MetricsProvider = Arc<dyn Fn() -> Result<Option<ScanMetricsSnapshot>, Error> + Send + Sync>;
33
34/// All lanes of one scan execution share these counters.
35/// This handle retains the final counters after stream cancellation.
36#[derive(Clone, Default)]
37pub struct ScanMetrics {
38    provider: Option<Arc<Mutex<Option<MetricsProvider>>>>,
39}
40
41impl ScanMetrics {
42    /// Adapt a backend's counters without extra work on each row or batch.
43    pub(crate) fn from_snapshot_fn(
44        provider: impl Fn() -> Result<ScanMetricsSnapshot, Error> + Send + Sync + 'static,
45    ) -> Self {
46        Self {
47            provider: Some(Arc::new(Mutex::new(Some(Arc::new(move || {
48                provider().map(Some)
49            }))))),
50        }
51    }
52
53    /// This method returns current counters, or None before execution starts or when the backend
54    /// supplies none. It does not read storage. Concurrent updates can span the snapshot.
55    pub fn snapshot(&self) -> Result<Option<ScanMetricsSnapshot>, Error> {
56        let provider = self
57            .provider
58            .as_ref()
59            .and_then(|provider| provider.lock().clone());
60        provider.map_or(Ok(None), |provider| provider())
61    }
62
63    fn pending() -> Self {
64        Self {
65            provider: Some(Arc::new(Mutex::new(None))),
66        }
67    }
68
69    fn bind(&self, source: Self) {
70        if let (Some(provider), Some(_)) = (&self.provider, &source.provider) {
71            *provider.lock() = Some(Arc::new(move || source.snapshot()));
72        }
73    }
74}
75
76/// One split of a scan's plan: stored rows of one fragment of the exact version, in stored order.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct ScanSplit {
79    /// The fragment's position in the version.
80    pub fragment: u64,
81    /// Half-open stored positions within the fragment, before filtering and deletion removal.
82    pub rows: Range<u64>,
83    /// Rows the split holds, before filtering, when planning estimates them.
84    pub estimated_rows: Option<u64>,
85    /// Bytes the split reads, when planning estimates them.
86    pub estimated_bytes: Option<u64>,
87    /// The stable ID of the fragment the split reads, which change plans and column writes name.
88    /// `None` when the scandal does not report it.
89    pub fragment_id: Option<Uuid>,
90}
91
92/// Stored rows of one split, read in stored order: a unit of work for one consumer of a scan,
93/// such as one worker of a distributed job. The application decides which consumer reads which
94/// items and in what order.
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct WorkItem {
97    /// Index of the split in [`Scan::splits`].
98    pub split: usize,
99    /// Half-open stored positions within the split's rows.
100    pub rows: Range<u64>,
101}
102
103/// Rows of one split of a scan, by position, for [`Scan::take`].
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct Take {
106    /// Index of the split in [`Scan::splits`].
107    pub split: usize,
108    /// Rows of the split by position. Position `p` is the `p`-th row, counted from zero in stored
109    /// order, that the scanned version keeps in the split, so a split holds as many positions as
110    /// [`Scan::count_rows`] counts for it whole. Positions may come in any order and repeat.
111    pub positions: Vec<u64>,
112}
113
114/// Projected Arrow schema and clause-by-clause predicate support.
115#[derive(Clone, Debug)]
116pub(crate) struct ScanInfo {
117    /// Projected Arrow schema, including field metadata.
118    pub schema: Schema,
119    pub source_fields: Option<BTreeMap<String, Uuid>>,
120    /// Support for each original top-level conjunction term.
121    pub filter_clauses: Vec<FilterClause>,
122    pub write_layout: Option<WriteLayout>,
123    pub statistics: Option<ScanStatistics>,
124}
125
126/// A filter that narrows a scan's splits as they open, such as a bound that tightens while the
127/// rows already read are consumed. Each split reads with the filter as it stands when the split
128/// opens and may skip every row the filter then rejects, so it must reject only rows the reader
129/// does not need.
130pub trait SplitFilter: Send + Sync {
131    /// The filter as it stands now, or `None` while it rejects nothing.
132    fn current(&self) -> Option<Expression>;
133}
134
135/// An opened execution binding retaining fixed options and authorized storage.
136#[async_trait]
137pub(crate) trait ScanSource: Send + Sync {
138    /// Start a fresh execution of the whole bound scan, each split narrowed by `narrowing`.
139    /// A source may ignore it.
140    ///
141    /// This operation must not resolve a table name or change the scan's options.
142    async fn read(&self, narrowing: Option<Arc<dyn SplitFilter>>) -> Result<BatchStream, Error>;
143
144    /// Fragment count and common ordering, when the source can preserve ordered streams.
145    fn ordered_partitions(&self) -> Option<(usize, Vec<SortColumn>)> {
146        None
147    }
148
149    /// Read the ordered fragments through at most `lanes` merged streams.
150    fn read_partitions(
151        &self,
152        _narrowing: Option<Arc<dyn SplitFilter>>,
153        _lanes: usize,
154        _batch_rows: usize,
155    ) -> Option<Vec<BatchStream>> {
156        None
157    }
158
159    /// A fresh binding of the scanned Table's storage, which evaluations over the scan's rows
160    /// use to read the Table's blobs. Sources without one lend no storage.
161    async fn table_binding(&self) -> Result<Option<TableBinding>, Error> {
162        Ok(None)
163    }
164
165    /// An estimate of the stored rows the bound scan reads. Sources without a plan estimate none.
166    fn estimated_rows(&self) -> Option<u64> {
167        None
168    }
169
170    /// The splits the bound scan reads, in stored order. Sources without a plan have none.
171    fn splits(&self) -> &[ScanSplit] {
172        &[]
173    }
174
175    /// Read the rows of validated `takes` in request order.
176    async fn take(&self, _takes: Vec<Take>) -> Result<BatchStream, Error> {
177        Err(Error::invalid("this scan has no splits to take rows from"))
178    }
179
180    /// Read validated `work` in order.
181    async fn read_work(&self, _work: Vec<WorkItem>) -> Result<WorkStream, Error> {
182        Err(Error::invalid("this scan has no splits to read"))
183    }
184
185    /// The rows each item of validated `work` returns after the scan's filter.
186    async fn count_rows(&self, _work: &[WorkItem]) -> Result<Vec<u64>, Error> {
187        Err(Error::invalid("this scan has no splits to count"))
188    }
189}
190
191/// One exact table scan with fixed options, metadata, and execution placement.
192#[derive(Clone)]
193pub struct Scan {
194    pub(crate) address: Option<ResourceAddress>,
195    identity: TableIdentity,
196    options: ScanOptions,
197    schema: SchemaRef,
198    source_fields: Option<BTreeMap<String, Uuid>>,
199    filter_clauses: Vec<FilterClause>,
200    write_layout: Option<WriteLayout>,
201    statistics: Option<ScanStatistics>,
202    stream_count: usize,
203    ordering: Vec<SortColumn>,
204    source: Arc<dyn ScanSource>,
205}
206
207impl Scan {
208    /// Bind validated metadata to a lazy source enforcing these options exactly.
209    ///
210    /// Unsupported predicates are rejected before reading. Stored-order scans,
211    /// limits, and explicit fragment selections use one execution lane.
212    pub(crate) fn new(
213        identity: TableIdentity,
214        options: ScanOptions,
215        info: ScanInfo,
216        source: Arc<dyn ScanSource>,
217    ) -> Result<Self, Error> {
218        require_exact_filter(&info.filter_clauses)?;
219        let max_streams = options.stream_count()?;
220        let ordered = source.ordered_partitions().filter(|(count, _)| {
221            (*count == 1
222                || (options.partitioned && options.limit.is_none() && options.row_range.is_none()))
223                && options.emit.is_none()
224        });
225        let (stream_count, ordering) = ordered
226            .map(|(count, ordering)| (count.min(max_streams), ordering))
227            .unwrap_or((max_streams, Vec::new()));
228        Ok(Self {
229            address: None,
230            identity,
231            options,
232            schema: Arc::new(info.schema),
233            source_fields: info.source_fields,
234            filter_clauses: info.filter_clauses,
235            write_layout: info.write_layout,
236            statistics: info.statistics,
237            stream_count,
238            ordering,
239            source,
240        })
241    }
242
243    /// A fresh binding of the scanned Table's storage, for evaluations that read its blobs.
244    pub(crate) async fn table_binding(&self) -> Result<Option<TableBinding>, Error> {
245        self.source.table_binding().await
246    }
247
248    /// Immutable version read by this scan.
249    pub fn identity(&self) -> TableIdentity {
250        self.identity
251    }
252
253    /// Canonical exact-version address inherited from a path-resolved Table.
254    /// ID-only scans expose their provenance through [`Self::identity`].
255    pub fn resolved_address(&self) -> Option<&ResourceAddress> {
256        self.address.as_ref()
257    }
258
259    /// Options fixed when the scan was opened.
260    pub fn options(&self) -> &ScanOptions {
261        &self.options
262    }
263
264    /// Projected schema, available without reading batches or performing I/O.
265    pub fn schema(&self) -> SchemaRef {
266        Arc::clone(&self.schema)
267    }
268
269    /// This method returns stable IDs for top-level stored columns, independent of the output
270    /// projection. `None` marks scans without stable identities, such as change scans or scans
271    /// from older hosts.
272    pub fn source_fields(&self) -> Option<&BTreeMap<String, Uuid>> {
273        self.source_fields.as_ref()
274    }
275
276    /// Preferred storage partitioning and clustering for writes to this exact table.
277    pub fn write_layout(&self) -> Option<&WriteLayout> {
278        self.write_layout.as_ref()
279    }
280
281    /// Stored statistics for this scan, with explicit exactness for counts and bounds.
282    pub fn statistics(&self) -> Option<&ScanStatistics> {
283        self.statistics.as_ref()
284    }
285
286    /// Ordering guaranteed independently within each execution stream, with no global order.
287    pub fn ordering(&self) -> &[SortColumn] {
288        &self.ordering
289    }
290
291    /// Exact support for the accepted predicate clauses.
292    pub fn filter_clauses(&self) -> &[FilterClause] {
293        &self.filter_clauses
294    }
295
296    /// An estimate of the stored rows this scan reads, known without reading any rows.
297    ///
298    /// It counts every row of the splits planning kept, so the scan's filter, limit, or emit can
299    /// return fewer rows. `None` when the plan does not estimate every split.
300    pub fn estimated_rows(&self) -> Option<u64> {
301        self.source.estimated_rows()
302    }
303
304    /// Number of execution lanes available through [`Self::into_streams`].
305    pub fn stream_count(&self) -> usize {
306        self.stream_count
307    }
308
309    /// The splits planning kept, in stored order: the units of work that [`Self::read_work`]
310    /// reads.
311    pub fn splits(&self) -> &[ScanSplit] {
312        self.source.splits()
313    }
314
315    /// Read `work` in order, each item's rows in stored order.
316    ///
317    /// Each batch carries the index of its item in `work` and the stored row after the rows it
318    /// covered, so a reader resumes an item from that row without reading any row twice. Items
319    /// name splits of this scan by index and stored rows within them. Scans with a limit or an
320    /// emit read no work items.
321    pub async fn read_work(&self, work: Vec<WorkItem>) -> Result<WorkStream, Error> {
322        self.validate_work(&work)?;
323        self.source.read_work(work).await
324    }
325
326    /// The exact rows each item of `work` returns: stored rows that pass the filter and are not
327    /// deleted. Counting reads only the filter's columns, and none without a filter.
328    pub async fn count_rows(&self, work: &[WorkItem]) -> Result<Vec<u64>, Error> {
329        self.validate_work(work)?;
330        self.source.count_rows(work).await
331    }
332
333    /// Read rows of this scan's splits by position, in request order: each take's rows in the
334    /// order of its positions, a repeated position's row again, the takes one after another.
335    ///
336    /// This is the read a sampler calls: plan the scan once, count each split's rows with
337    /// [`Self::count_rows`], then take the positions it draws. Each split's rows are read in one
338    /// pass, so positions that share storage share its reads, and takes beyond one request's
339    /// bounds are sent as several. A failed request is resumed from the rows it delivered.
340    /// A position past its split fails the call before any rows are delivered. Scans with a
341    /// filter, a limit or an emit take no rows.
342    pub async fn take(&self, takes: Vec<Take>) -> Result<BatchStream, Error> {
343        if self.options.filter.is_some()
344            || self.options.limit.is_some()
345            || self.options.emit.is_some()
346        {
347            return Err(Error::invalid(
348                "scans with a filter, a limit or an emit take no rows by position",
349            ));
350        }
351        let splits = self.splits().len();
352        if let Some(take) = takes.iter().find(|take| take.split >= splits) {
353            return Err(Error::invalid(format!(
354                "a take names split {} of a scan with {splits} splits",
355                take.split
356            )));
357        }
358        self.source.take(takes).await
359    }
360
361    fn validate_work(&self, work: &[WorkItem]) -> Result<(), Error> {
362        if self.options.limit.is_some() || self.options.emit.is_some() {
363            return Err(Error::invalid(
364                "scans with a limit or an emit read no work items",
365            ));
366        }
367        let splits = self.splits();
368        for item in work {
369            let split = splits.get(item.split).ok_or_else(|| {
370                Error::invalid(format!(
371                    "work names split {} of a scan with {} splits",
372                    item.split,
373                    splits.len()
374                ))
375            })?;
376            if item.rows.start > item.rows.end
377                || item.rows.start < split.rows.start
378                || item.rows.end > split.rows.end
379            {
380                return Err(Error::invalid(format!(
381                    "work rows {:?} are outside split {} rows {:?}",
382                    item.rows, item.split, split.rows
383                )));
384            }
385        }
386        Ok(())
387    }
388
389    /// Consume the scan as one batch stream. Reading starts on first poll.
390    /// Unordered options permit unordered output even when using one consumer.
391    pub fn into_stream(self) -> BatchStream {
392        if !self.ordering.is_empty() {
393            return self
394                .source
395                .read_partitions(None, 1, self.options.batch_rows.unwrap_or(65_536))
396                .expect("ordered source supplies fixed partitions")
397                .pop()
398                .expect("one ordered lane");
399        }
400        self.open_stream(None)
401    }
402
403    /// Consume the scan as disjoint streams that together contain its selected rows.
404    ///
405    /// Ordered lanes merge fixed groups of fragments when [`Self::ordering`] is nonempty.
406    /// Other lanes share work dynamically. Consume all lanes concurrently for throughput.
407    /// A global limit or explicit split yields one lane. Clone the scan, not a lane,
408    /// to start a fresh execution without resolving metadata again.
409    /// Dropping the last active lane cancels remaining work, including idle lanes.
410    pub fn into_streams(self) -> Vec<ScanStream> {
411        self.lanes(None)
412    }
413
414    /// [`Self::into_streams`], with each split narrowed by `filter` as it stands when the split
415    /// opens.
416    pub fn into_narrowed_streams(self, filter: Arc<dyn SplitFilter>) -> Vec<ScanStream> {
417        self.lanes(Some(filter))
418    }
419
420    fn lanes(self, narrowing: Option<Arc<dyn SplitFilter>>) -> Vec<ScanStream> {
421        if !self.ordering.is_empty() {
422            return self
423                .source
424                .read_partitions(
425                    narrowing,
426                    self.stream_count,
427                    self.options.batch_rows.unwrap_or(65_536),
428                )
429                .expect("ordered source supplies fixed partitions")
430                .into_iter()
431                .map(|stream| ScanStream {
432                    schema: stream.schema(),
433                    metrics: stream.metrics(),
434                    diagnostics: stream.diagnostics(),
435                    stream: Box::pin(stream),
436                })
437                .collect();
438        }
439        let count = self.stream_count;
440        let schema = self.schema();
441        let stream = self.open_stream(narrowing);
442        let metrics = stream.metrics();
443        let diagnostics = stream.diagnostics();
444        let shared = Arc::new(LaneSource {
445            state: Mutex::new(LaneState {
446                stream: Some(stream),
447                active: 0,
448                failure: None,
449            }),
450            waiters: (0..count).map(|_| AtomicWaker::new()).collect(),
451        });
452        (0..count)
453            .map(|index| ScanStream {
454                schema: Arc::clone(&schema),
455                metrics: metrics.clone(),
456                diagnostics: diagnostics.clone(),
457                stream: Box::pin(Lane {
458                    shared: Arc::clone(&shared),
459                    index,
460                    active: false,
461                }),
462            })
463            .collect()
464    }
465
466    fn open_stream(self, narrowing: Option<Arc<dyn SplitFilter>>) -> BatchStream {
467        let schema = self.schema();
468        let metrics = if self.options.collect_metrics {
469            ScanMetrics::pending()
470        } else {
471            ScanMetrics::default()
472        };
473        let execution_metrics = metrics.clone();
474        let diagnostics = ExecutionDiagnostics::default();
475        let execution_diagnostics = diagnostics.clone();
476        let opened = stream::once(async move {
477            let batches = self.source.read(narrowing).await?;
478            execution_metrics.bind(batches.metrics());
479            execution_diagnostics.bind(batches.diagnostics());
480            if PlanDescription::row_schema(&batches.schema())
481                != PlanDescription::row_schema(&self.schema)
482            {
483                return Err(Error::arrow(
484                    "opened stream schema differs from scan schema",
485                ));
486            }
487            Ok(batches)
488        });
489        BatchStream::from_stream(schema, opened.try_flatten())
490            .with_metrics(metrics)
491            .with_diagnostics(diagnostics)
492    }
493}
494
495/// One execution lane of a fixed scan.
496pub struct ScanStream {
497    diagnostics: ExecutionDiagnostics,
498    schema: SchemaRef,
499    metrics: ScanMetrics,
500    stream: Pin<Box<dyn Stream<Item = Result<RecordBatch, Error>> + Send>>,
501}
502
503impl ScanStream {
504    /// Return the counters shared by every lane of this execution.
505    pub fn metrics(&self) -> ScanMetrics {
506        self.metrics.clone()
507    }
508
509    /// Fixed projected schema shared by all lanes of the scan.
510    pub fn schema(&self) -> SchemaRef {
511        Arc::clone(&self.schema)
512    }
513
514    /// Read this lane lazily. Dropping the last active lane cancels remaining work.
515    pub fn into_stream(self) -> BatchStream {
516        BatchStream::from_stream(self.schema, self.stream)
517            .with_metrics(self.metrics)
518            .with_diagnostics(self.diagnostics)
519    }
520}
521
522struct LaneSource {
523    state: Mutex<LaneState>,
524    waiters: Vec<AtomicWaker>,
525}
526
527struct LaneState {
528    stream: Option<BatchStream>,
529    active: usize,
530    failure: Option<String>,
531}
532
533impl ArcWake for LaneSource {
534    fn wake_by_ref(shared: &Arc<Self>) {
535        for waiter in &shared.waiters {
536            waiter.wake();
537        }
538    }
539}
540
541struct Lane {
542    shared: Arc<LaneSource>,
543    index: usize,
544    active: bool,
545}
546
547impl Stream for Lane {
548    type Item = Result<RecordBatch, Error>;
549
550    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
551        if !self.active {
552            self.shared.state.lock().active += 1;
553            self.active = true;
554        }
555        self.shared.waiters[self.index].register(cx.waker());
556        let waker = waker_ref(&self.shared);
557        let mut source_context = Context::from_waker(&waker);
558        let result = {
559            let mut state = self.shared.state.lock();
560            if let Some(failure) = &state.failure {
561                return Poll::Ready(Some(Err(Error::arrow(failure))));
562            }
563            let Some(stream) = &mut state.stream else {
564                return Poll::Ready(None);
565            };
566            let result = Pin::new(stream).poll_next(&mut source_context);
567            match &result {
568                Poll::Ready(Some(Err(error))) => {
569                    state.failure = Some(error.to_string());
570                    state.stream = None;
571                }
572                Poll::Ready(None) => state.stream = None,
573                _ => {}
574            }
575            result
576        };
577        if result.is_ready() {
578            LaneSource::wake_by_ref(&self.shared);
579        }
580        result
581    }
582}
583
584impl Drop for Lane {
585    fn drop(&mut self) {
586        self.shared.waiters[self.index].take();
587        if self.active {
588            let mut state = self.shared.state.lock();
589            state.active -= 1;
590            if state.active == 0 && state.stream.take().is_some() {
591                state.failure = Some("scan execution cancelled".into());
592            }
593        }
594    }
595}
596
597/// A batch of one work item and where the item's read stands after it.
598#[derive(Clone, Debug)]
599pub struct WorkBatch {
600    pub batch: RecordBatch,
601    /// The batch's item, by index in the work that was read.
602    pub item: usize,
603    /// The stored row after the rows this batch covered; the item resumes from here.
604    pub next_row: u64,
605}
606
607/// Batches of work items, in the items' order. A failure terminates the stream.
608pub struct WorkStream {
609    schema: SchemaRef,
610    stream: Pin<Box<dyn Stream<Item = Result<WorkBatch, Error>> + Send>>,
611}
612
613impl WorkStream {
614    pub(crate) fn new(
615        schema: SchemaRef,
616        stream: impl Stream<Item = Result<WorkBatch, Error>> + Send + 'static,
617    ) -> Self {
618        Self {
619            schema,
620            stream: Box::pin(stream),
621        }
622    }
623
624    /// Fixed projected schema.
625    pub fn schema(&self) -> SchemaRef {
626        Arc::clone(&self.schema)
627    }
628
629    /// Read the next batch asynchronously.
630    pub async fn next_batch(&mut self) -> Option<Result<WorkBatch, Error>> {
631        self.next().await
632    }
633}
634
635impl Stream for WorkStream {
636    type Item = Result<WorkBatch, Error>;
637    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
638        self.stream.as_mut().poll_next(context)
639    }
640}
641
642/// Pull-driven Arrow batches with a fixed schema. A failure terminates the stream.
643pub struct BatchStream {
644    schema: SchemaRef,
645    diagnostics: ExecutionDiagnostics,
646    metrics: ScanMetrics,
647    stream: Pin<Box<dyn Stream<Item = Result<RecordBatch, Error>> + Send>>,
648}
649
650impl BatchStream {
651    /// Adapt an asynchronous source without eager polling or additional buffering.
652    pub fn from_stream(
653        schema: SchemaRef,
654        stream: impl Stream<Item = Result<RecordBatch, Error>> + Send + 'static,
655    ) -> Self {
656        let expected = Arc::clone(&schema);
657        let stream = stream::unfold(Some(stream.boxed()), move |source| {
658            let expected = Arc::clone(&expected);
659            async move {
660                let mut source = source?;
661                let batch = source.next().await?.and_then(|batch| {
662                    if PlanDescription::row_schema(&batch.schema())
663                        != PlanDescription::row_schema(&expected)
664                    {
665                        return Err(Error::arrow("batch schema differs from scan schema"));
666                    }
667                    Ok(batch)
668                });
669                let source = batch.is_ok().then_some(source);
670                Some((batch, source))
671            }
672        });
673        Self {
674            schema,
675            metrics: ScanMetrics::default(),
676            diagnostics: ExecutionDiagnostics::default(),
677            stream: Box::pin(stream),
678        }
679    }
680
681    /// Set generic execution options on this stream's request schema.
682    pub fn with_execution_options(mut self, options: ExecutionOptions) -> Result<Self, Error> {
683        self.schema = Arc::new(options.with_schema(&self.schema)?);
684        Ok(self)
685    }
686
687    /// Retain the execution descriptions observed while consuming this stream.
688    /// Profiles update on pulls; dropping the stream marks incomplete observations cancelled.
689    pub fn diagnostics(&self) -> ExecutionDiagnostics {
690        self.diagnostics.clone()
691    }
692
693    pub(crate) fn with_diagnostics(mut self, diagnostics: ExecutionDiagnostics) -> Self {
694        self.diagnostics = diagnostics;
695        self
696    }
697
698    /// Attach execution counters supplied by this stream's backend.
699    pub fn with_metrics(mut self, metrics: ScanMetrics) -> Self {
700        self.metrics = metrics;
701        self
702    }
703
704    /// Return execution counters without advancing the stream.
705    pub fn metrics(&self) -> ScanMetrics {
706        self.metrics.clone()
707    }
708
709    /// Adapt a synchronous Arrow reader on a blocking worker with bounded buffering.
710    #[expect(
711        clippy::disallowed_methods,
712        reason = "The open SDK adapts blocking Arrow callbacks without a Vortex runtime"
713    )]
714    pub fn from_reader(mut reader: Box<dyn RecordBatchReader + Send>) -> Self {
715        let schema = reader.schema();
716        let (sender, receiver) = mpsc::channel(2);
717        let worker = spawn_blocking(move || {
718            while !sender.is_closed() {
719                let Some(batch) = reader.next() else { break };
720                let failed = batch.is_err();
721                if sender.blocking_send(batch.map_err(Error::arrow)).is_err() || failed {
722                    break;
723                }
724            }
725        });
726        Self::from_stream(schema, ReaderStream { receiver, worker })
727    }
728
729    /// Fixed projected schema.
730    pub fn schema(&self) -> SchemaRef {
731        Arc::clone(&self.schema)
732    }
733
734    /// Read the next batch asynchronously.
735    pub async fn next_batch(&mut self) -> Option<Result<RecordBatch, Error>> {
736        self.next().await
737    }
738}
739
740impl Stream for BatchStream {
741    type Item = Result<RecordBatch, Error>;
742    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
743        for _ in 0..32 {
744            let next = self.stream.as_mut().poll_next(context);
745            if let Poll::Ready(Some(Ok(batch))) = &next {
746                match self.diagnostics.observe(batch.schema_ref()) {
747                    Ok(true) if batch.num_rows() == 0 => continue,
748                    Ok(_) => {}
749                    Err(error) => return Poll::Ready(Some(Err(error.into()))),
750                }
751            }
752            return next;
753        }
754        context.waker().wake_by_ref();
755        Poll::Pending
756    }
757}
758
759impl Drop for BatchStream {
760    fn drop(&mut self) {
761        self.diagnostics.cancelled();
762    }
763}
764
765struct ReaderStream {
766    receiver: mpsc::Receiver<Result<RecordBatch, Error>>,
767    worker: JoinHandle<()>,
768}
769
770impl Stream for ReaderStream {
771    type Item = Result<RecordBatch, Error>;
772    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
773        self.receiver.poll_recv(context)
774    }
775}
776
777impl Drop for ReaderStream {
778    fn drop(&mut self) {
779        self.receiver.close();
780        self.worker.abort();
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use std::sync::atomic::{AtomicUsize, Ordering};
787
788    use arrow_array::Int64Array;
789    use arrow_schema::{DataType, Field};
790    use uuid::Uuid;
791
792    use super::*;
793
794    struct WorkSource {
795        reads: AtomicUsize,
796        drops: Arc<AtomicUsize>,
797        schema: SchemaRef,
798        fail: bool,
799    }
800
801    struct WorkLifetime(Arc<AtomicUsize>);
802    impl Drop for WorkLifetime {
803        fn drop(&mut self) {
804            self.0.fetch_add(1, Ordering::SeqCst);
805        }
806    }
807
808    #[async_trait]
809    impl ScanSource for WorkSource {
810        async fn read(&self, _: Option<Arc<dyn SplitFilter>>) -> Result<BatchStream, Error> {
811            self.reads.fetch_add(1, Ordering::SeqCst);
812            let schema = Arc::clone(&self.schema);
813            let fail = self.fail;
814            let guard = WorkLifetime(Arc::clone(&self.drops));
815            let rows = Arc::new(AtomicUsize::new(0));
816            let counters = Arc::clone(&rows);
817            let metrics = ScanMetrics::from_snapshot_fn(move || {
818                Ok(ScanMetricsSnapshot {
819                    output_rows: (counters.load(Ordering::Relaxed) as u64).into(),
820                    ..Default::default()
821                })
822            });
823            Ok(BatchStream::from_stream(
824                Arc::clone(&schema),
825                stream::unfold((0, guard), move |(index, guard)| {
826                    let schema = Arc::clone(&schema);
827                    let rows = Arc::clone(&rows);
828                    async move {
829                        if index == 100 {
830                            return None;
831                        }
832                        let batch = if fail && index == 1 {
833                            Err(Error::arrow("work failed"))
834                        } else {
835                            Ok(RecordBatch::try_new(
836                                schema,
837                                vec![Arc::new(Int64Array::from(vec![index]))],
838                            )
839                            .unwrap())
840                        };
841                        if batch.is_ok() {
842                            rows.fetch_add(1, Ordering::Relaxed);
843                        }
844                        Some((batch, (index + 1, guard)))
845                    }
846                }),
847            )
848            .with_metrics(metrics))
849        }
850    }
851
852    fn work_scan(fail: bool) -> (Scan, Arc<WorkSource>) {
853        let schema = Arc::new(Schema::new(vec![Field::new(
854            "value",
855            DataType::Int64,
856            false,
857        )]));
858        let source = Arc::new(WorkSource {
859            reads: AtomicUsize::new(0),
860            drops: Arc::new(AtomicUsize::new(0)),
861            schema: Arc::clone(&schema),
862            fail,
863        });
864        let scan = Scan::new(
865            TableIdentity {
866                resource_id: Uuid::new_v4(),
867                version_id: Uuid::new_v4(),
868            },
869            ScanOptions {
870                collect_metrics: true,
871                partitioned: true,
872                max_streams: Some(2),
873                ..Default::default()
874            },
875            ScanInfo {
876                source_fields: None,
877                statistics: None,
878                write_layout: None,
879                schema: schema.as_ref().clone(),
880                filter_clauses: vec![],
881            },
882            source.clone(),
883        )
884        .unwrap();
885        (scan, source)
886    }
887
888    #[tokio::test]
889    async fn metrics_follow_execution_lifetime_and_replay() {
890        let (scan, _) = work_scan(false);
891        let mut lanes = scan.clone().into_streams().into_iter();
892        let first = lanes.next().unwrap();
893        let second = lanes.next().unwrap();
894        let first_metrics = first.metrics();
895        let second_metrics = second.metrics();
896        assert!(first_metrics.snapshot().unwrap().is_none());
897        let mut first = first.into_stream();
898        let mut second = second.into_stream();
899        first.next_batch().await.unwrap().unwrap();
900        second.next_batch().await.unwrap().unwrap();
901        assert_eq!(first_metrics.snapshot().unwrap().unwrap().output_rows.0, 2);
902        assert_eq!(
903            first_metrics.snapshot().unwrap(),
904            second_metrics.snapshot().unwrap()
905        );
906        drop(first);
907        drop(second);
908        assert_eq!(first_metrics.snapshot().unwrap().unwrap().output_rows.0, 2);
909        let mut replay = scan.into_stream();
910        let replay_metrics = replay.metrics();
911        assert!(replay_metrics.snapshot().unwrap().is_none());
912        replay.next_batch().await.unwrap().unwrap();
913        assert_eq!(replay_metrics.snapshot().unwrap().unwrap().output_rows.0, 1);
914        assert_eq!(first_metrics.snapshot().unwrap().unwrap().output_rows.0, 2);
915    }
916
917    #[tokio::test]
918    async fn lanes_share_work_and_replay_creates_a_fresh_execution() {
919        let (scan, source) = work_scan(false);
920        let mut lanes = scan
921            .clone()
922            .into_streams()
923            .into_iter()
924            .map(ScanStream::into_stream);
925        let mut slow = lanes.next().unwrap();
926        let mut fast = lanes.next().unwrap();
927        assert_eq!(source.reads.load(Ordering::SeqCst), 0);
928        let first = slow.next_batch().await.unwrap().unwrap();
929        assert_eq!(
930            first
931                .column(0)
932                .as_any()
933                .downcast_ref::<Int64Array>()
934                .unwrap()
935                .value(0),
936            0
937        );
938        for value in 1..100 {
939            let batch = fast.next_batch().await.unwrap().unwrap();
940            assert_eq!(
941                batch
942                    .column(0)
943                    .as_any()
944                    .downcast_ref::<Int64Array>()
945                    .unwrap()
946                    .value(0),
947                value
948            );
949        }
950        assert!(fast.next_batch().await.is_none());
951        assert!(slow.next_batch().await.is_none());
952        assert_eq!(source.reads.load(Ordering::SeqCst), 1);
953        assert_eq!(source.drops.load(Ordering::SeqCst), 1);
954        let batches = scan.into_stream().collect::<Vec<_>>().await;
955        assert_eq!(batches.len(), 100);
956        assert_eq!(source.reads.load(Ordering::SeqCst), 2);
957    }
958
959    #[tokio::test]
960    async fn lane_failure_and_last_active_lane_cancellation_release_work() {
961        let (scan, source) = work_scan(true);
962        let mut lanes = scan.into_streams().into_iter().map(ScanStream::into_stream);
963        let mut first = lanes.next().unwrap();
964        let mut second = lanes.next().unwrap();
965        first.next_batch().await.unwrap().unwrap();
966        assert!(second.next_batch().await.unwrap().is_err());
967        assert!(first.next_batch().await.unwrap().is_err());
968        assert!(first.next_batch().await.is_none());
969        assert!(second.next_batch().await.is_none());
970        assert_eq!(source.drops.load(Ordering::SeqCst), 1);
971
972        let (scan, source) = work_scan(false);
973        let mut lanes = scan.into_streams().into_iter().map(ScanStream::into_stream);
974        let mut active = lanes.next().unwrap();
975        let mut idle = lanes.next().unwrap();
976        active.next_batch().await.unwrap().unwrap();
977        drop(active);
978        assert_eq!(
979            source.drops.load(Ordering::SeqCst),
980            1,
981            "idle lanes must not retain active I/O"
982        );
983        assert!(idle.next_batch().await.unwrap().is_err());
984        assert_eq!(source.reads.load(Ordering::SeqCst), 1);
985    }
986
987    #[tokio::test]
988    async fn batch_schema_mismatch_is_terminal() {
989        let expected = Arc::new(Schema::new(vec![Field::new(
990            "expected",
991            DataType::Int64,
992            false,
993        )]));
994        let wrong = RecordBatch::try_new(
995            Arc::new(Schema::new(vec![Field::new(
996                "wrong",
997                DataType::Int64,
998                false,
999            )])),
1000            vec![Arc::new(Int64Array::from(vec![1]))],
1001        )
1002        .unwrap();
1003        let valid = RecordBatch::try_new(
1004            Arc::clone(&expected),
1005            vec![Arc::new(Int64Array::from(vec![2]))],
1006        )
1007        .unwrap();
1008        let polled = Arc::new(AtomicUsize::new(0));
1009        let counter = Arc::clone(&polled);
1010        let source = stream::iter([Ok(wrong), Ok(valid)]).inspect(move |_| {
1011            counter.fetch_add(1, Ordering::SeqCst);
1012        });
1013        let mut batches = BatchStream::from_stream(expected, source);
1014        assert!(matches!(
1015            batches.next_batch().await,
1016            Some(Err(Error::Arrow(_)))
1017        ));
1018        assert!(batches.next_batch().await.is_none());
1019        assert_eq!(polled.load(Ordering::SeqCst), 1);
1020    }
1021}