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