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 opened_count > 0 {
249            let avg = sum / opened_count;
250            let extrapolated = avg.saturating_mul(total_count);
251            Precision::inexact(extrapolated)
252        } else {
253            Precision::Absent
254        }
255    }
256
257    fn byte_size(&self) -> Precision<u64> {
258        let total_count = self.children.len() as u64;
259        if total_count == 0 {
260            return Precision::exact(0u64);
261        }
262
263        let mut sum: u64 = 0;
264        let mut known_count: u64 = 0;
265        for child in self.children.iter() {
266            if let Some(size) = child.byte_size() {
267                sum = sum.saturating_add(size);
268                known_count += 1;
269            }
270        }
271
272        if known_count == 0 {
273            return Precision::Absent;
274        }
275
276        if known_count == total_count {
277            Precision::exact(sum)
278        } else {
279            let avg = sum / known_count;
280            let extrapolated = avg.saturating_mul(total_count);
281            Precision::inexact(extrapolated)
282        }
283    }
284
285    fn deserialize_partition(
286        &self,
287        _data: &[u8],
288        _session: &VortexSession,
289    ) -> VortexResult<PartitionRef> {
290        vortex_bail!("MultiLayoutDataSource partitions are not yet serializable")
291    }
292
293    async fn scan(&self, scan_request: ScanRequest) -> VortexResult<DataSourceScanRef> {
294        let mut ready = VecDeque::new();
295        let mut deferred = VecDeque::new();
296
297        for child in self.children.iter() {
298            match child {
299                MultiLayoutChild::Opened { reader, .. } => ready.push_back(Arc::clone(reader)),
300                MultiLayoutChild::Deferred { factory, .. } => {
301                    deferred.push_back(Arc::clone(factory))
302                }
303            }
304        }
305
306        let request = BoundScanRequest::try_new(scan_request, &self.dtype)?;
307        let dtype = request.projection.dtype().clone();
308
309        Ok(Box::new(MultiLayoutScan {
310            session: self.session.clone(),
311            source_dtype: self.dtype.clone(),
312            dtype,
313            request,
314            ready,
315            deferred,
316            handle: self.session.handle(),
317            concurrency: self.concurrency,
318        }))
319    }
320
321    async fn field_statistics(&self, _field_path: &FieldPath) -> VortexResult<StatsSet> {
322        Ok(StatsSet::default())
323    }
324}
325
326#[derive(Clone)]
327struct BoundScanRequest {
328    projection: BoundExpression,
329    filter: SharedVortexResult<Option<BoundExpression>>,
330    row_range: Option<Range<u64>>,
331    selection: Selection,
332    partition_selection: Selection,
333    partition_range: Option<Range<u64>>,
334    ordered: bool,
335    limit: Option<u64>,
336}
337
338impl BoundScanRequest {
339    fn try_new(request: ScanRequest, dtype: &DType) -> VortexResult<Self> {
340        let ScanRequest {
341            projection,
342            filter,
343            row_range,
344            selection,
345            partition_selection,
346            partition_range,
347            ordered,
348            limit,
349        } = request;
350
351        Ok(Self {
352            projection: projection.optimize_recursive(dtype)?.bind(dtype)?,
353            filter: filter
354                .map(|expr| expr.optimize_recursive(dtype)?.bind(dtype))
355                .transpose()
356                .map_err(Arc::new),
357            row_range,
358            selection,
359            partition_selection,
360            partition_range,
361            ordered,
362            limit,
363        })
364    }
365}
366
367struct MultiLayoutScan {
368    session: VortexSession,
369    source_dtype: DType,
370    dtype: DType,
371    request: BoundScanRequest,
372    ready: VecDeque<LayoutReaderRef>,
373    deferred: VecDeque<Arc<dyn LayoutReaderFactory>>,
374    handle: vortex_io::runtime::Handle,
375    concurrency: usize,
376}
377
378impl DataSourceScan for MultiLayoutScan {
379    fn dtype(&self) -> &DType {
380        &self.dtype
381    }
382
383    fn partition_count(&self) -> Precision<usize> {
384        let count = self.ready.len() + self.deferred.len();
385        if self.deferred.is_empty() {
386            Precision::exact(count)
387        } else {
388            Precision::inexact(count)
389        }
390    }
391
392    fn partitions(self: Box<Self>) -> PartitionStream {
393        let Self {
394            session,
395            source_dtype,
396            dtype: _,
397            request,
398            ready,
399            deferred,
400            handle,
401            concurrency,
402        } = *self;
403
404        let ordered = request.ordered;
405
406        // Pre-opened readers are immediately available.
407        let ready_stream = stream::iter(ready).map(Ok);
408
409        // Deferred readers are opened concurrently via spawned tasks.
410        // When ordered, we use `buffered` to preserve the original partition order.
411        // When unordered, we use `buffer_unordered` to yield partitions as they open.
412        let spawned = stream::iter(deferred).map(move |factory| {
413            handle.spawn(async move {
414                factory
415                    .open()
416                    .instrument(tracing::info_span!("LayoutReaderFactory::open"))
417                    .await
418            })
419        });
420
421        let deferred_stream = if ordered {
422            spawned
423                .buffered(concurrency)
424                .filter_map(|result| async move {
425                    match result {
426                        Ok(Some(reader)) => Some(Ok(reader)),
427                        Ok(None) => None,
428                        Err(e) => Some(Err(e)),
429                    }
430                })
431                .boxed()
432        } else {
433            spawned
434                .buffer_unordered(concurrency)
435                .filter_map(|result| async move {
436                    match result {
437                        Ok(Some(reader)) => Some(Ok(reader)),
438                        Ok(None) => None,
439                        Err(e) => Some(Err(e)),
440                    }
441                })
442                .boxed()
443        };
444
445        // For each reader (ready or just-opened), generate a partition.
446        // Partition generation is synchronous (just creates structs with row ranges), so
447        // `flat_map` is appropriate here. The real I/O work happens when `execute()` is called.
448        ready_stream
449            .chain(deferred_stream)
450            .enumerate()
451            .flat_map(move |(i, reader_result)| match reader_result {
452                Ok(reader) => {
453                    reader_partition(i, reader, session.clone(), &source_dtype, request.clone())
454                }
455                Err(e) => stream::once(async move { Err(e) }).boxed(),
456            })
457            .boxed()
458    }
459}
460
461/// Generates a partition stream for a single layout reader.
462///
463/// Checks file-level pruning first (via `pruning_evaluation`). If the filter proves no rows
464/// can match, returns an empty stream. Otherwise, yields a single partition covering the
465/// reader's full row range.
466fn reader_partition(
467    partition_idx: usize,
468    reader: LayoutReaderRef,
469    session: VortexSession,
470    source_dtype: &DType,
471    request: BoundScanRequest,
472) -> PartitionStream {
473    if reader.dtype() != source_dtype {
474        let error = vortex_err!(
475            "Multi-layout reader dtype mismatch: expected {}, got {}",
476            source_dtype,
477            reader.dtype()
478        );
479        return stream::once(async move { Err(error) }).boxed();
480    }
481
482    let row_count = reader.row_count();
483    let row_range = request.row_range.clone().unwrap_or(0..row_count);
484
485    let partition_idx_u64: u64 = partition_idx as u64;
486    if let Some(range) = &request.partition_range
487        && !range.contains(&partition_idx_u64)
488    {
489        return stream::empty().boxed();
490    };
491    match &request.partition_selection {
492        Selection::IncludeByIndex(buffer) => {
493            if buffer.as_slice().binary_search(&partition_idx_u64).is_err() {
494                return stream::empty().boxed();
495            }
496        }
497        Selection::ExcludeByIndex(buffer) => {
498            if buffer.as_slice().binary_search(&partition_idx_u64).is_ok() {
499                return stream::empty().boxed();
500            }
501        }
502        _ => {}
503    };
504
505    // Check file-level pruning: if the filter can be proven false for the entire row range
506    // using file-level statistics, skip this reader entirely.
507    if let Ok(Some(filter)) = &request.filter {
508        let mask_len = usize::try_from(row_range.end - row_range.start).unwrap_or(usize::MAX);
509        let mask = Mask::new_true(mask_len);
510        if let Ok(pruning_future) = reader.pruning_evaluation(&row_range, filter, mask)
511            && let Some(Ok(result_mask)) = pruning_future.now_or_never()
512            && result_mask.all_false()
513        {
514            return stream::empty().boxed();
515        }
516    }
517
518    stream::once(async move {
519        Ok(Box::new(MultiLayoutPartition {
520            reader,
521            session,
522            request: BoundScanRequest {
523                row_range: Some(row_range),
524                ..request
525            },
526            index: partition_idx,
527        }) as PartitionRef)
528    })
529    .boxed()
530}
531
532/// A partition backed by a single [`LayoutReaderRef`] and a row range.
533///
534/// On `execute()`, creates a [`ScanBuilder`] over the row range, enabling
535/// internal I/O pipelining and split-level parallelism within the reader.
536struct MultiLayoutPartition {
537    reader: LayoutReaderRef,
538    session: VortexSession,
539    request: BoundScanRequest,
540    index: usize,
541}
542
543impl Partition for MultiLayoutPartition {
544    fn as_any(&self) -> &dyn Any {
545        self
546    }
547
548    fn index(&self) -> usize {
549        self.index
550    }
551
552    fn row_count(&self) -> Precision<u64> {
553        let Some(row_range) = self.request.row_range.as_ref() else {
554            return Precision::Absent;
555        };
556        let row_count = row_range.end - row_range.start;
557        let row_count = self.request.selection.row_count(row_count);
558        let row_count = self
559            .request
560            .limit
561            .map_or(row_count, |limit| row_count.min(limit));
562
563        let has_filter = match &self.request.filter {
564            Ok(filter) => filter.is_some(),
565            Err(_) => true,
566        };
567        if has_filter {
568            Precision::inexact(row_count)
569        } else {
570            Precision::exact(row_count)
571        }
572    }
573
574    fn byte_size(&self) -> Precision<u64> {
575        Precision::Absent
576    }
577
578    fn execute(self: Box<Self>) -> VortexResult<SendableArrayStream> {
579        let request = self.request;
580        let filter = request.filter?;
581        let mut builder = ScanBuilder::new(self.session, self.reader)
582            .with_selection(request.selection)
583            .with_projection(request.projection)
584            .with_some_filter(filter)
585            .with_some_limit(request.limit)
586            .with_ordered(request.ordered);
587
588        if let Some(row_range) = request.row_range {
589            builder = builder.with_row_range(row_range);
590        }
591
592        let dtype = builder.dtype()?;
593        let stream = builder.into_stream()?;
594
595        Ok(ArrayStreamExt::boxed(ArrayStreamAdapter::new(
596            dtype, stream,
597        )))
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use rstest::rstest;
604    use vortex_array::dtype::Nullability;
605    use vortex_array::dtype::PType;
606    use vortex_array::expr::eq;
607    use vortex_array::expr::lit;
608    use vortex_array::expr::root;
609
610    use super::*;
611    use crate::scan::test::new_session;
612
613    struct NeverOpened;
614
615    #[async_trait]
616    impl LayoutReaderFactory for NeverOpened {
617        async fn open(&self) -> VortexResult<Option<LayoutReaderRef>> {
618            unreachable!("byte_size must not open readers")
619        }
620    }
621
622    fn deferred_source(byte_sizes: Vec<Option<u64>>) -> MultiLayoutDataSource {
623        let factories: Vec<Arc<dyn LayoutReaderFactory>> = byte_sizes
624            .iter()
625            .map(|_| Arc::new(NeverOpened) as _)
626            .collect();
627        MultiLayoutDataSource::new_deferred(
628            DType::Bool(Nullability::NonNullable),
629            factories,
630            byte_sizes,
631            &new_session(),
632        )
633    }
634
635    #[rstest]
636    #[case::all_known(vec![Some(10), Some(20), Some(30)], Precision::exact(60u64))]
637    #[case::some_known_extrapolates(vec![Some(10), None, Some(30)], Precision::inexact(60u64))]
638    #[case::none_known(vec![None, None], Precision::Absent)]
639    #[case::no_children(vec![], Precision::exact(0u64))]
640    fn byte_size_precision(#[case] sizes: Vec<Option<u64>>, #[case] expected: Precision<u64>) {
641        assert_eq!(deferred_source(sizes).byte_size(), expected);
642    }
643
644    #[test]
645    fn filter_binding_errors_are_deferred() -> VortexResult<()> {
646        let dtype = DType::Primitive(PType::U8, Nullability::NonNullable);
647        let request = ScanRequest {
648            filter: Some(eq(root(), lit(67_i32))),
649            ..ScanRequest::default()
650        };
651
652        let request = BoundScanRequest::try_new(request, &dtype)?;
653
654        assert_eq!(request.projection.dtype(), &dtype);
655        assert!(request.filter.is_err());
656        Ok(())
657    }
658}