Skip to main content

vortex_layout/scan/
multi.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! A [`DataSource`] that combines multiple [`LayoutReaderRef`]s into a single scannable source.
5//!
6//! Readers may be pre-opened or deferred via [`LayoutReaderFactory`]. Deferred readers are opened
7//! concurrently during scanning using `buffer_unordered`: up to `concurrency` file opens run in
8//! parallel as spawned tasks on the session runtime. Once opened, each reader yields a single
9//! partition covering its full row range; internal I/O pipelining and chunking are handled by
10//! [`ScanBuilder`].
11//!
12//! # Schema Resolution
13//!
14//! Currently, all children must share the exact same [`DType`]. A dtype
15//! mismatch produces an error.
16//!
17//! # Future Work
18//!
19//! - **Schema union**: Allow missing columns (filled with nulls) and compatible type upcasts
20//!   across sources instead of requiring exact dtype matches.
21//! - **Hive-style partitioning**: Extract partition values from file paths (e.g. `year=2024/month=01/`)
22//!   and expose them as virtual columns.
23//! - **Virtual columns**: `filename`, `file_row_number`, `file_index`.
24//! - **Per-file statistics**: Merge column statistics across sources for planner hints.
25//! - **Error resilience**: Skip failed sources instead of aborting the entire scan.
26
27use std::any::Any;
28use std::collections::VecDeque;
29use std::ops::Range;
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use futures::FutureExt;
34use futures::StreamExt;
35use futures::stream;
36use itertools::Itertools;
37use tracing::Instrument;
38use vortex_array::dtype::DType;
39use vortex_array::dtype::FieldPath;
40use vortex_array::expr::BoundExpression;
41use vortex_array::expr::stats::Precision;
42use vortex_array::stats::StatsSet;
43use vortex_array::stream::ArrayStreamAdapter;
44use vortex_array::stream::ArrayStreamExt;
45use vortex_array::stream::SendableArrayStream;
46use vortex_error::SharedVortexResult;
47use vortex_error::VortexResult;
48use vortex_error::vortex_bail;
49use vortex_error::vortex_err;
50use vortex_io::session::RuntimeSessionExt;
51use vortex_mask::Mask;
52use vortex_scan::DataSource;
53use vortex_scan::DataSourceScan;
54use vortex_scan::DataSourceScanRef;
55use vortex_scan::Partition;
56use vortex_scan::PartitionRef;
57use vortex_scan::PartitionStream;
58use vortex_scan::ScanRequest;
59use vortex_scan::selection::Selection;
60use vortex_session::VortexSession;
61use vortex_utils::parallelism::get_available_parallelism;
62
63use crate::LayoutReaderRef;
64use crate::scan::scan_builder::ScanBuilder;
65
66/// Default concurrency for opening deferred readers.
67const DEFAULT_CONCURRENCY: usize = 8;
68
69/// An async factory that produces a [`LayoutReaderRef`].
70///
71/// Implementations handle file opening, footer caching, and statistics-based pruning.
72/// Returns `None` if the source should be skipped (e.g., pruned based on file-level
73/// statistics before the reader is fully constructed).
74#[async_trait]
75pub trait LayoutReaderFactory: 'static + Send + Sync {
76    /// Opens the layout reader, or returns `None` if it should be skipped.
77    async fn open(&self) -> VortexResult<Option<LayoutReaderRef>>;
78}
79
80/// A [`DataSource`] that combines multiple [`LayoutReaderRef`]s into a single scannable source.
81///
82/// Readers may be pre-opened or deferred via [`LayoutReaderFactory`]. Deferred readers are opened
83/// concurrently during scanning using `buffer_unordered`, mirroring the DuckDB scan pattern: up
84/// to `concurrency` file opens run in parallel as spawned tasks on the session runtime. Once
85/// opened, each reader yields a single partition covering its full row range; internal I/O
86/// pipelining and chunking are handled by [`ScanBuilder`].
87#[derive(Clone)]
88pub struct MultiLayoutDataSource {
89    dtype: DType,
90    session: VortexSession,
91    children: Arc<[MultiLayoutChild]>,
92    concurrency: usize,
93}
94
95pub enum MultiLayoutChild {
96    Opened {
97        reader: LayoutReaderRef,
98        /// On-storage file size in bytes, if known from the listing metadata.
99        byte_size: Option<u64>,
100    },
101    Deferred {
102        factory: Arc<dyn LayoutReaderFactory>,
103        /// On-storage file size in bytes, if known from the listing metadata.
104        byte_size: Option<u64>,
105    },
106}
107
108impl MultiLayoutChild {
109    /// On-storage file size in bytes for this child, if known.
110    pub fn byte_size(&self) -> Option<u64> {
111        match self {
112            MultiLayoutChild::Opened { byte_size, .. } => *byte_size,
113            MultiLayoutChild::Deferred { byte_size, .. } => *byte_size,
114        }
115    }
116}
117
118impl MultiLayoutDataSource {
119    /// Creates a multi-layout data source with the first reader pre-opened.
120    ///
121    /// The first reader determines the dtype. Remaining readers are opened lazily during
122    /// scanning via their factories. `byte_sizes` carries the on-storage file size in bytes for
123    /// each child (first followed by remaining); pass `None` for entries where the size is
124    /// unknown. Must be empty or have length `1 + remaining.len()`.
125    pub fn new_with_first(
126        first: LayoutReaderRef,
127        remaining: Vec<Arc<dyn LayoutReaderFactory>>,
128        byte_sizes: Vec<Option<u64>>,
129        session: &VortexSession,
130    ) -> Self {
131        let dtype = first.dtype().clone();
132        let concurrency = get_available_parallelism().unwrap_or(DEFAULT_CONCURRENCY);
133
134        let total = 1 + remaining.len();
135        let mut sizes = byte_sizes;
136        if sizes.is_empty() {
137            sizes = vec![None; total];
138        }
139        debug_assert_eq!(
140            sizes.len(),
141            total,
142            "byte_sizes length must match the number of children"
143        );
144
145        let mut children = Vec::with_capacity(total);
146        let mut sizes_iter = sizes.into_iter();
147        let first_size = sizes_iter.next().unwrap_or(None);
148        children.push(MultiLayoutChild::Opened {
149            reader: first,
150            byte_size: first_size,
151        });
152        children.extend(
153            remaining
154                .into_iter()
155                .zip_eq(sizes_iter)
156                .map(|(factory, byte_size)| MultiLayoutChild::Deferred { factory, byte_size }),
157        );
158
159        Self {
160            dtype,
161            session: session.clone(),
162            children: children.into(),
163            concurrency,
164        }
165    }
166
167    /// Creates a multi-layout data source where all children are deferred.
168    ///
169    /// The dtype must be provided externally since there is no pre-opened reader to infer it
170    /// from. This avoids eagerly opening any file when the schema is already known (e.g. from
171    /// a catalog or a prior scan). `byte_sizes` carries the on-storage file size in bytes for
172    /// each factory; pass `None` for entries where the size is unknown. Must be empty or have
173    /// the same length as `factories`.
174    pub fn new_deferred(
175        dtype: DType,
176        factories: Vec<Arc<dyn LayoutReaderFactory>>,
177        byte_sizes: Vec<Option<u64>>,
178        session: &VortexSession,
179    ) -> Self {
180        let concurrency = get_available_parallelism().unwrap_or(DEFAULT_CONCURRENCY);
181
182        let mut sizes = byte_sizes;
183        if sizes.is_empty() {
184            sizes = vec![None; factories.len()];
185        }
186        debug_assert_eq!(
187            sizes.len(),
188            factories.len(),
189            "byte_sizes length must match the number of factories"
190        );
191
192        Self {
193            dtype,
194            session: session.clone(),
195            children: factories
196                .into_iter()
197                .zip_eq(sizes)
198                .map(|(factory, byte_size)| MultiLayoutChild::Deferred { factory, byte_size })
199                .collect(),
200            concurrency,
201        }
202    }
203
204    pub fn children(&self) -> &[MultiLayoutChild] {
205        &self.children
206    }
207
208    /// Sets the concurrency for opening deferred readers.
209    ///
210    /// Controls how many file opens run in parallel via `buffer_unordered`.
211    /// Defaults to the number of available CPU cores.
212    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
213        self.concurrency = concurrency;
214        self
215    }
216}
217
218#[async_trait]
219impl DataSource for MultiLayoutDataSource {
220    fn dtype(&self) -> &DType {
221        &self.dtype
222    }
223
224    fn row_count(&self) -> Precision<u64> {
225        let mut sum: u64 = 0;
226        let mut opened_count: u64 = 0;
227        let mut deferred_count: u64 = 0;
228
229        for child in self.children.iter() {
230            match child {
231                MultiLayoutChild::Opened { reader, .. } => {
232                    opened_count += 1;
233                    sum = sum.saturating_add(reader.row_count());
234                }
235                MultiLayoutChild::Deferred { .. } => {
236                    deferred_count += 1;
237                }
238            }
239        }
240
241        let total_count = opened_count + deferred_count;
242        if total_count == 0 {
243            return Precision::exact(0u64);
244        }
245
246        if deferred_count == 0 {
247            Precision::exact(sum)
248        } else if let Some(avg) = sum.checked_div(opened_count) {
249            let extrapolated = avg.saturating_mul(total_count);
250            Precision::inexact(extrapolated)
251        } else {
252            Precision::Absent
253        }
254    }
255
256    fn byte_size(&self) -> Precision<u64> {
257        let total_count = self.children.len() as u64;
258        if total_count == 0 {
259            return Precision::exact(0u64);
260        }
261
262        let mut sum: u64 = 0;
263        let mut known_count: u64 = 0;
264        for child in self.children.iter() {
265            if let Some(size) = child.byte_size() {
266                sum = sum.saturating_add(size);
267                known_count += 1;
268            }
269        }
270
271        if known_count == 0 {
272            return Precision::Absent;
273        }
274
275        if known_count == total_count {
276            Precision::exact(sum)
277        } else {
278            let avg = sum / known_count;
279            let extrapolated = avg.saturating_mul(total_count);
280            Precision::inexact(extrapolated)
281        }
282    }
283
284    fn deserialize_partition(
285        &self,
286        _data: &[u8],
287        _session: &VortexSession,
288    ) -> VortexResult<PartitionRef> {
289        vortex_bail!("MultiLayoutDataSource partitions are not yet serializable")
290    }
291
292    async fn scan(&self, scan_request: ScanRequest) -> VortexResult<DataSourceScanRef> {
293        let mut ready = VecDeque::new();
294        let mut deferred = VecDeque::new();
295
296        for child in self.children.iter() {
297            match child {
298                MultiLayoutChild::Opened { reader, .. } => ready.push_back(Arc::clone(reader)),
299                MultiLayoutChild::Deferred { factory, .. } => {
300                    deferred.push_back(Arc::clone(factory))
301                }
302            }
303        }
304
305        let request = BoundScanRequest::try_new(scan_request, &self.dtype)?;
306        let dtype = request.projection.dtype().clone();
307
308        Ok(Box::new(MultiLayoutScan {
309            session: self.session.clone(),
310            source_dtype: self.dtype.clone(),
311            dtype,
312            request,
313            ready,
314            deferred,
315            handle: self.session.handle(),
316            concurrency: self.concurrency,
317        }))
318    }
319
320    async fn field_statistics(&self, _field_path: &FieldPath) -> VortexResult<StatsSet> {
321        Ok(StatsSet::default())
322    }
323}
324
325#[derive(Clone)]
326struct BoundScanRequest {
327    projection: BoundExpression,
328    filter: SharedVortexResult<Option<BoundExpression>>,
329    row_range: Option<Range<u64>>,
330    selection: Selection,
331    partition_selection: Selection,
332    partition_range: Option<Range<u64>>,
333    ordered: bool,
334    limit: Option<u64>,
335}
336
337impl BoundScanRequest {
338    fn try_new(request: ScanRequest, dtype: &DType) -> VortexResult<Self> {
339        let ScanRequest {
340            projection,
341            filter,
342            row_range,
343            selection,
344            partition_selection,
345            partition_range,
346            ordered,
347            limit,
348        } = request;
349
350        Ok(Self {
351            projection: projection.bind(dtype)?.optimize_recursive()?,
352            filter: filter
353                .map(|expr| expr.bind(dtype)?.optimize_recursive())
354                .transpose()
355                .map_err(Arc::new),
356            row_range,
357            selection,
358            partition_selection,
359            partition_range,
360            ordered,
361            limit,
362        })
363    }
364}
365
366struct MultiLayoutScan {
367    session: VortexSession,
368    source_dtype: DType,
369    dtype: DType,
370    request: BoundScanRequest,
371    ready: VecDeque<LayoutReaderRef>,
372    deferred: VecDeque<Arc<dyn LayoutReaderFactory>>,
373    handle: vortex_io::runtime::Handle,
374    concurrency: usize,
375}
376
377impl DataSourceScan for MultiLayoutScan {
378    fn dtype(&self) -> &DType {
379        &self.dtype
380    }
381
382    fn partition_count(&self) -> Precision<usize> {
383        let count = self.ready.len() + self.deferred.len();
384        if self.deferred.is_empty() {
385            Precision::exact(count)
386        } else {
387            Precision::inexact(count)
388        }
389    }
390
391    fn partitions(self: Box<Self>) -> PartitionStream {
392        let Self {
393            session,
394            source_dtype,
395            dtype: _,
396            request,
397            ready,
398            deferred,
399            handle,
400            concurrency,
401        } = *self;
402
403        let ordered = request.ordered;
404
405        // Pre-opened readers are immediately available.
406        let ready_stream = stream::iter(ready).map(Ok);
407
408        // Deferred readers are opened concurrently via spawned tasks.
409        // When ordered, we use `buffered` to preserve the original partition order.
410        // When unordered, we use `buffer_unordered` to yield partitions as they open.
411        let spawned = stream::iter(deferred).map(move |factory| {
412            handle.spawn(async move {
413                factory
414                    .open()
415                    .instrument(tracing::info_span!("LayoutReaderFactory::open"))
416                    .await
417            })
418        });
419
420        let deferred_stream = if ordered {
421            spawned
422                .buffered(concurrency)
423                .filter_map(|result| async move {
424                    match result {
425                        Ok(Some(reader)) => Some(Ok(reader)),
426                        Ok(None) => None,
427                        Err(e) => Some(Err(e)),
428                    }
429                })
430                .boxed()
431        } else {
432            spawned
433                .buffer_unordered(concurrency)
434                .filter_map(|result| async move {
435                    match result {
436                        Ok(Some(reader)) => Some(Ok(reader)),
437                        Ok(None) => None,
438                        Err(e) => Some(Err(e)),
439                    }
440                })
441                .boxed()
442        };
443
444        // For each reader (ready or just-opened), generate a partition.
445        // Partition generation is synchronous (just creates structs with row ranges), so
446        // `flat_map` is appropriate here. The real I/O work happens when `execute()` is called.
447        ready_stream
448            .chain(deferred_stream)
449            .enumerate()
450            .flat_map(move |(i, reader_result)| match reader_result {
451                Ok(reader) => {
452                    reader_partition(i, reader, session.clone(), &source_dtype, request.clone())
453                }
454                Err(e) => stream::once(async move { Err(e) }).boxed(),
455            })
456            .boxed()
457    }
458}
459
460/// Generates a partition stream for a single layout reader.
461///
462/// Checks file-level pruning first (via `pruning_evaluation`). If the filter proves no rows
463/// can match, returns an empty stream. Otherwise, yields a single partition covering the
464/// reader's full row range.
465fn reader_partition(
466    partition_idx: usize,
467    reader: LayoutReaderRef,
468    session: VortexSession,
469    source_dtype: &DType,
470    request: BoundScanRequest,
471) -> PartitionStream {
472    if reader.dtype() != source_dtype {
473        let error = vortex_err!(
474            "Multi-layout reader dtype mismatch: expected {}, got {}",
475            source_dtype,
476            reader.dtype()
477        );
478        return stream::once(async move { Err(error) }).boxed();
479    }
480
481    let row_count = reader.row_count();
482    let row_range = request.row_range.clone().unwrap_or(0..row_count);
483
484    let partition_idx_u64: u64 = partition_idx as u64;
485    if let Some(range) = &request.partition_range
486        && !range.contains(&partition_idx_u64)
487    {
488        return stream::empty().boxed();
489    };
490    match &request.partition_selection {
491        Selection::IncludeByIndex(buffer)
492            if buffer.as_slice().binary_search(&partition_idx_u64).is_err() =>
493        {
494            return stream::empty().boxed();
495        }
496        Selection::ExcludeByIndex(buffer)
497            if buffer.as_slice().binary_search(&partition_idx_u64).is_ok() =>
498        {
499            return stream::empty().boxed();
500        }
501        _ => {}
502    };
503
504    // Check file-level pruning: if the filter can be proven false for the entire row range
505    // using file-level statistics, skip this reader entirely.
506    if let Ok(Some(filter)) = &request.filter {
507        let mask_len = usize::try_from(row_range.end - row_range.start).unwrap_or(usize::MAX);
508        let mask = Mask::new_true(mask_len);
509        if let Ok(pruning_future) = reader.pruning_evaluation(&row_range, filter, mask)
510            && let Some(Ok(result_mask)) = pruning_future.now_or_never()
511            && result_mask.all_false()
512        {
513            return stream::empty().boxed();
514        }
515    }
516
517    stream::once(async move {
518        Ok(Box::new(MultiLayoutPartition {
519            reader,
520            session,
521            request: BoundScanRequest {
522                row_range: Some(row_range),
523                ..request
524            },
525            index: partition_idx,
526        }) as PartitionRef)
527    })
528    .boxed()
529}
530
531/// A partition backed by a single [`LayoutReaderRef`] and a row range.
532///
533/// On `execute()`, creates a [`ScanBuilder`] over the row range, enabling
534/// internal I/O pipelining and split-level parallelism within the reader.
535struct MultiLayoutPartition {
536    reader: LayoutReaderRef,
537    session: VortexSession,
538    request: BoundScanRequest,
539    index: usize,
540}
541
542impl Partition for MultiLayoutPartition {
543    fn as_any(&self) -> &dyn Any {
544        self
545    }
546
547    fn index(&self) -> usize {
548        self.index
549    }
550
551    fn row_count(&self) -> Precision<u64> {
552        let Some(row_range) = self.request.row_range.as_ref() else {
553            return Precision::Absent;
554        };
555        let row_count = row_range.end - row_range.start;
556        let row_count = self.request.selection.row_count(row_count);
557        let row_count = self
558            .request
559            .limit
560            .map_or(row_count, |limit| row_count.min(limit));
561
562        let has_filter = match &self.request.filter {
563            Ok(filter) => filter.is_some(),
564            Err(_) => true,
565        };
566        if has_filter {
567            Precision::inexact(row_count)
568        } else {
569            Precision::exact(row_count)
570        }
571    }
572
573    fn byte_size(&self) -> Precision<u64> {
574        Precision::Absent
575    }
576
577    fn execute(self: Box<Self>) -> VortexResult<SendableArrayStream> {
578        let request = self.request;
579        let filter = request.filter?;
580        let mut builder = ScanBuilder::new(self.session, self.reader)
581            .with_selection(request.selection)
582            .with_projection(request.projection)
583            .with_some_filter(filter)
584            .with_some_limit(request.limit)
585            .with_ordered(request.ordered);
586
587        if let Some(row_range) = request.row_range {
588            builder = builder.with_row_range(row_range);
589        }
590
591        let dtype = builder.dtype()?;
592        let stream = builder.into_stream()?;
593
594        Ok(ArrayStreamExt::boxed(ArrayStreamAdapter::new(
595            dtype, stream,
596        )))
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use rstest::rstest;
603    use vortex_array::dtype::Nullability;
604    use vortex_array::dtype::PType;
605    use vortex_array::expr::eq;
606    use vortex_array::expr::lit;
607    use vortex_array::expr::root;
608
609    use super::*;
610    use crate::scan::test::new_session;
611
612    struct NeverOpened;
613
614    #[async_trait]
615    impl LayoutReaderFactory for NeverOpened {
616        async fn open(&self) -> VortexResult<Option<LayoutReaderRef>> {
617            unreachable!("byte_size must not open readers")
618        }
619    }
620
621    fn deferred_source(byte_sizes: Vec<Option<u64>>) -> MultiLayoutDataSource {
622        let factories: Vec<Arc<dyn LayoutReaderFactory>> = byte_sizes
623            .iter()
624            .map(|_| Arc::new(NeverOpened) as _)
625            .collect();
626        MultiLayoutDataSource::new_deferred(
627            DType::Bool(Nullability::NonNullable),
628            factories,
629            byte_sizes,
630            &new_session(),
631        )
632    }
633
634    #[rstest]
635    #[case::all_known(vec![Some(10), Some(20), Some(30)], Precision::exact(60u64))]
636    #[case::some_known_extrapolates(vec![Some(10), None, Some(30)], Precision::inexact(60u64))]
637    #[case::none_known(vec![None, None], Precision::Absent)]
638    #[case::no_children(vec![], Precision::exact(0u64))]
639    fn byte_size_precision(#[case] sizes: Vec<Option<u64>>, #[case] expected: Precision<u64>) {
640        assert_eq!(deferred_source(sizes).byte_size(), expected);
641    }
642
643    #[test]
644    fn filter_binding_errors_are_deferred() -> VortexResult<()> {
645        let dtype = DType::Primitive(PType::U8, Nullability::NonNullable);
646        let request = ScanRequest {
647            filter: Some(eq(root(), lit(67_i32))),
648            ..ScanRequest::default()
649        };
650
651        let request = BoundScanRequest::try_new(request, &dtype)?;
652
653        assert_eq!(request.projection.dtype(), &dtype);
654        assert!(request.filter.is_err());
655        Ok(())
656    }
657}