Skip to main content

vortex_layout/scan/
layout.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::any::Any;
5use std::ops::Range;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::task::Context;
9use std::task::Poll;
10
11use async_trait::async_trait;
12use futures::FutureExt;
13use futures::Stream;
14use futures::stream;
15use futures::stream::StreamExt;
16use vortex_array::IntoArray;
17use vortex_array::arrays::ConstantArray;
18use vortex_array::dtype::DType;
19use vortex_array::dtype::FieldPath;
20use vortex_array::dtype::Nullability;
21use vortex_array::expr::BoundExpression;
22use vortex_array::expr::stats::Precision;
23use vortex_array::scalar::Scalar;
24use vortex_array::stats::StatsSet;
25use vortex_array::stream::ArrayStreamAdapter;
26use vortex_array::stream::ArrayStreamExt;
27use vortex_array::stream::SendableArrayStream;
28use vortex_error::VortexResult;
29use vortex_error::vortex_bail;
30use vortex_mask::Mask;
31use vortex_metrics::MetricsRegistry;
32use vortex_scan::DataSource;
33use vortex_scan::DataSourceScan;
34use vortex_scan::DataSourceScanRef;
35use vortex_scan::Partition;
36use vortex_scan::PartitionRef;
37use vortex_scan::PartitionStream;
38use vortex_scan::ScanRequest;
39use vortex_scan::selection::Selection;
40use vortex_session::VortexSession;
41
42use crate::LayoutReaderRef;
43use crate::scan::limit::RowLimit;
44use crate::scan::scan_builder::ScanBuilder;
45
46/// An implementation of a [`DataSource`] that reads data from a [`LayoutReaderRef`].
47pub struct LayoutReaderDataSource {
48    reader: LayoutReaderRef,
49    session: VortexSession,
50    split_max_row_count: u64,
51    metrics_registry: Option<Arc<dyn MetricsRegistry>>,
52}
53
54impl LayoutReaderDataSource {
55    /// Creates a new [`LayoutReaderDataSource`].
56    ///
57    /// By default, the entire scan is returned as a single split. This best preserves V1
58    /// `ScanBuilder` behavior where one scan covers the full row range, allowing the internal
59    /// I/O pipeline and `SplitBy::Layout` chunking to operate without per-split overhead from
60    /// redundant expression resolution and layout tree traversal.
61    pub fn new(reader: LayoutReaderRef, session: VortexSession) -> Self {
62        Self {
63            reader,
64            session,
65            split_max_row_count: u64::MAX,
66            metrics_registry: None,
67        }
68    }
69
70    /// Sets the maximum number of rows per Scan API split.
71    ///
72    /// Each split drives a [`ScanBuilder`] over its row range, which internally handles
73    /// physical layout alignment and I/O pipelining. This controls the engine-level
74    /// parallelism granularity, not the I/O granularity.
75    pub fn with_split_max_row_count(mut self, row_count: u64) -> Self {
76        self.split_max_row_count = row_count;
77        self
78    }
79
80    /// Sets the metrics registry for tracking scan performance.
81    pub fn with_metrics_registry(mut self, metrics: Arc<dyn MetricsRegistry>) -> Self {
82        self.metrics_registry = Some(metrics);
83        self
84    }
85
86    /// Optionally sets the metrics registry for tracking scan performance.
87    pub fn with_some_metrics_registry(mut self, metrics: Option<Arc<dyn MetricsRegistry>>) -> Self {
88        self.metrics_registry = metrics;
89        self
90    }
91}
92
93#[async_trait]
94impl DataSource for LayoutReaderDataSource {
95    fn dtype(&self) -> &DType {
96        self.reader.dtype()
97    }
98
99    fn row_count(&self) -> Precision<u64> {
100        Precision::exact(self.reader.row_count())
101    }
102
103    fn byte_size(&self) -> Precision<u64> {
104        Precision::Absent
105    }
106
107    fn deserialize_partition(
108        &self,
109        _data: &[u8],
110        _session: &VortexSession,
111    ) -> VortexResult<PartitionRef> {
112        vortex_bail!("LayoutReader splits are not yet serializable");
113    }
114
115    async fn scan(&self, scan_request: ScanRequest) -> VortexResult<DataSourceScanRef> {
116        let total_rows = self.reader.row_count();
117        let row_range = scan_request.row_range.unwrap_or(0..total_rows);
118
119        let projection = scan_request
120            .projection
121            .bind(self.reader.dtype())?
122            .optimize_recursive()?;
123        let filter = scan_request
124            .filter
125            .map(|expr| expr.bind(self.reader.dtype())?.optimize_recursive())
126            .transpose()?;
127        let dtype = projection.dtype().clone();
128
129        // If the dtype is an empty struct, and there is no filter, we can return a special
130        // length-only scan.
131        if let DType::Struct(fields, Nullability::NonNullable) = &dtype
132            && fields.nfields() == 0
133            && filter.is_none()
134        {
135            // FIXME(ngates): extract out maybe?
136            let row_count = scan_request.selection.row_count_in_range(&row_range);
137
138            // Apply the limit.
139            let row_count = if let Some(limit) = scan_request.limit {
140                row_count.min(limit)
141            } else {
142                row_count
143            };
144
145            return Ok(Box::new(Empty { dtype, row_count }));
146        }
147
148        // Check file-level pruning: if the filter can be proven false for the entire row range
149        // using file-level statistics (e.g. via FileStatsLayoutReader), skip the scan entirely.
150        if let Some(filter) = &filter {
151            let mask = Mask::new_true(
152                usize::try_from(row_range.end - row_range.start).unwrap_or(usize::MAX),
153            );
154            let pruning_result = self
155                .reader
156                .pruning_evaluation(&row_range, filter, mask)?
157                .now_or_never();
158            if let Some(Ok(result_mask)) = pruning_result
159                && result_mask.all_false()
160            {
161                return Ok(Box::new(Empty {
162                    dtype,
163                    row_count: 0,
164                }));
165            }
166        }
167
168        // Only unordered scans share a limit across external partitions: reservation order is
169        // completion order, which an ordered scan cannot accept. Ordered partitions each apply
170        // the limit locally and the engine trims the concatenated result.
171        let row_limit = (!scan_request.ordered)
172            .then(|| scan_request.limit.map(RowLimit::new))
173            .flatten();
174
175        Ok(Box::new(LayoutReaderScan {
176            reader: Arc::clone(&self.reader),
177            session: self.session.clone(),
178            dtype,
179            projection,
180            filter,
181            remaining_limit: scan_request.limit,
182            row_limit,
183            selection: scan_request.selection,
184            ordered: scan_request.ordered,
185            metrics_registry: self.metrics_registry.clone(),
186            next_row: row_range.start,
187            end_row: row_range.end,
188            split_size: self.split_max_row_count,
189        }))
190    }
191
192    async fn field_statistics(&self, _field_path: &FieldPath) -> VortexResult<StatsSet> {
193        Ok(StatsSet::default())
194    }
195}
196
197struct LayoutReaderScan {
198    reader: LayoutReaderRef,
199    session: VortexSession,
200    dtype: DType,
201    projection: BoundExpression,
202    filter: Option<BoundExpression>,
203    /// Limit rows not yet covered by the splits produced so far; production stops at zero.
204    /// Decremented only for filterless scans, where each split's output is exactly its
205    /// selected row count.
206    remaining_limit: Option<u64>,
207    /// A row budget shared by every split of an unordered limited scan; see [`RowLimit`].
208    row_limit: Option<RowLimit>,
209    ordered: bool,
210    selection: Selection,
211    metrics_registry: Option<Arc<dyn MetricsRegistry>>,
212    next_row: u64,
213    end_row: u64,
214    split_size: u64,
215}
216
217impl DataSourceScan for LayoutReaderScan {
218    fn dtype(&self) -> &DType {
219        &self.dtype
220    }
221
222    fn partition_count(&self) -> Precision<usize> {
223        let (lower, upper) = self.size_hint();
224        match upper {
225            Some(u) if u == lower => Precision::exact(lower),
226            Some(u) => Precision::inexact(u),
227            None => Precision::inexact(lower),
228        }
229    }
230
231    fn partitions(self: Box<Self>) -> PartitionStream {
232        (*self).boxed()
233    }
234}
235
236impl Stream for LayoutReaderScan {
237    type Item = VortexResult<PartitionRef>;
238
239    fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
240        let this = self.get_mut();
241
242        if this.next_row >= this.end_row {
243            return Poll::Ready(None);
244        }
245
246        if this.remaining_limit.is_some_and(|limit| limit == 0) {
247            return Poll::Ready(None);
248        }
249        if this.row_limit.as_ref().is_some_and(RowLimit::is_exhausted) {
250            return Poll::Ready(None);
251        }
252
253        let split_end = this
254            .next_row
255            .saturating_add(this.split_size)
256            .min(this.end_row);
257        let row_range = this.next_row..split_end;
258
259        // Snapshot before decrementing: a split's cap must cover the rows it will itself return.
260        let split_limit = this.remaining_limit;
261        // Without a filter, a split returns exactly its selected rows, so the countdown stays
262        // exact. With a filter the split's contribution is unknowable up front, so every split
263        // receives the full remaining limit and the total is enforced by the shared `row_limit`
264        // (unordered scans) or by the engine trimming the concatenated result (ordered scans).
265        if this.filter.is_none()
266            && let Some(ref mut remaining) = this.remaining_limit
267        {
268            *remaining = remaining.saturating_sub(this.selection.row_count_in_range(&row_range));
269        }
270
271        let split = Box::new(LayoutReaderSplit {
272            reader: Arc::clone(&this.reader),
273            session: this.session.clone(),
274            projection: this.projection.clone(),
275            filter: this.filter.clone(),
276            limit: split_limit,
277            row_limit: this.row_limit.clone(),
278            ordered: this.ordered,
279            row_range,
280            selection: this.selection.clone(),
281            metrics_registry: this.metrics_registry.clone(),
282        }) as PartitionRef;
283
284        this.next_row = split_end;
285
286        Poll::Ready(Some(Ok(split)))
287    }
288
289    fn size_hint(&self) -> (usize, Option<usize>) {
290        if self.next_row >= self.end_row
291            || self.remaining_limit.is_some_and(|limit| limit == 0)
292            || self.row_limit.as_ref().is_some_and(RowLimit::is_exhausted)
293        {
294            return (0, Some(0));
295        }
296        let remaining_rows = self.end_row - self.next_row;
297        let splits = remaining_rows.div_ceil(self.split_size);
298        (0, Some(usize::try_from(splits).unwrap_or(usize::MAX)))
299    }
300}
301
302struct LayoutReaderSplit {
303    reader: LayoutReaderRef,
304    session: VortexSession,
305    projection: BoundExpression,
306    filter: Option<BoundExpression>,
307    /// Cap on this split's output rows: the scan's remaining limit when the split was produced.
308    limit: Option<u64>,
309    /// A row budget shared with the scan's other splits (unordered limited scans only).
310    row_limit: Option<RowLimit>,
311    ordered: bool,
312    row_range: Range<u64>,
313    selection: Selection,
314    metrics_registry: Option<Arc<dyn MetricsRegistry>>,
315}
316
317impl Partition for LayoutReaderSplit {
318    fn as_any(&self) -> &dyn Any {
319        self
320    }
321
322    #[expect(clippy::cast_possible_truncation)]
323    fn index(&self) -> usize {
324        // Row range is unique per split
325        self.row_range.start as usize
326    }
327
328    fn row_count(&self) -> Precision<u64> {
329        let row_count = self.selection.row_count_in_range(&self.row_range);
330        let row_count = self.limit.map_or(row_count, |limit| row_count.min(limit));
331
332        if self.filter.is_some() || self.row_limit.is_some() {
333            Precision::inexact(row_count)
334        } else {
335            Precision::exact(row_count)
336        }
337    }
338
339    fn byte_size(&self) -> Precision<u64> {
340        Precision::Absent
341    }
342
343    fn execute(self: Box<Self>) -> VortexResult<SendableArrayStream> {
344        let builder = ScanBuilder::new(self.session, self.reader)
345            .with_row_range(self.row_range)
346            .with_selection(self.selection)
347            .with_projection(self.projection)
348            .with_some_filter(self.filter)
349            .with_some_metrics_registry(self.metrics_registry)
350            .with_ordered(self.ordered);
351        let builder = match self.row_limit {
352            Some(row_limit) => builder.with_shared_limit(row_limit),
353            None => builder.with_some_limit(self.limit),
354        };
355
356        let dtype = builder.dtype()?;
357        // Use into_stream() which creates a LazyScanStream that spawns individual I/O
358        // tasks onto the runtime, enabling parallel execution across executor threads.
359        let stream = builder.into_stream()?.boxed();
360
361        Ok(ArrayStreamExt::boxed(ArrayStreamAdapter::new(
362            dtype, stream,
363        )))
364    }
365}
366
367/// A scan that produces no data, only empty arrays with the correct row count.
368struct Empty {
369    dtype: DType,
370    row_count: u64,
371}
372
373impl DataSourceScan for Empty {
374    fn dtype(&self) -> &DType {
375        &self.dtype
376    }
377
378    fn partition_count(&self) -> Precision<usize> {
379        Precision::exact(1usize)
380    }
381
382    fn partitions(self: Box<Self>) -> PartitionStream {
383        stream::iter([Ok(self as _)]).boxed()
384    }
385}
386
387impl Partition for Empty {
388    fn as_any(&self) -> &dyn Any {
389        self
390    }
391
392    fn index(&self) -> usize {
393        0
394    }
395
396    fn row_count(&self) -> Precision<u64> {
397        Precision::exact(self.row_count)
398    }
399
400    fn byte_size(&self) -> Precision<u64> {
401        Precision::exact(0u64)
402    }
403
404    fn execute(mut self: Box<Self>) -> VortexResult<SendableArrayStream> {
405        let scalar = Scalar::default_value(&self.dtype);
406        let dtype = self.dtype.clone();
407
408        // Create an iterator of arrays with the correct row count, respecting u64::MAX limits.
409        let iter = std::iter::from_fn(move || {
410            if self.row_count == 0 {
411                return None;
412            }
413            let chunk_size = usize::try_from(self.row_count).unwrap_or(usize::MAX);
414            self.row_count -= chunk_size as u64;
415            Some(VortexResult::Ok(
416                ConstantArray::new(scalar.clone(), chunk_size).into_array(),
417            ))
418        });
419
420        Ok(ArrayStreamExt::boxed(ArrayStreamAdapter::new(
421            dtype,
422            stream::iter(iter),
423        )))
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use std::sync::Arc;
430
431    use futures::StreamExt;
432    use futures::TryStreamExt;
433    use rstest::rstest;
434    use vortex_array::expr::root;
435    use vortex_buffer::Buffer;
436    use vortex_error::VortexResult;
437    use vortex_io::runtime::BlockingRuntime;
438    use vortex_io::runtime::single::SingleThreadRuntime;
439    use vortex_scan::DataSource;
440    use vortex_scan::ScanRequest;
441    use vortex_scan::selection::Selection;
442    use vortex_scan::strict_sorted_buffer::StrictSortedBuffer;
443
444    use super::LayoutReaderDataSource;
445    use crate::scan::test::TestLayoutReader;
446    use crate::scan::test::collect_scan_values;
447    use crate::scan::test::session_with_handle;
448
449    /// An unordered limit is shared by every partition of the scan, so the partitions together
450    /// return no more rows than the limit.
451    #[test]
452    fn unordered_limit_is_shared_across_partitions() -> VortexResult<()> {
453        let runtime = SingleThreadRuntime::default();
454        let session = session_with_handle(runtime.handle());
455        let source = LayoutReaderDataSource::new(Arc::new(TestLayoutReader::new(12)), session)
456            .with_split_max_row_count(2);
457
458        let scan = runtime.block_on(source.scan(ScanRequest {
459            filter: Some(root()),
460            limit: Some(3),
461            ordered: false,
462            ..Default::default()
463        }))?;
464        let partitions = runtime.block_on(scan.partitions().try_collect::<Vec<_>>())?;
465        assert_eq!(partitions.len(), 6);
466
467        let chunks = runtime.block_on(
468            futures::stream::iter(partitions)
469                .map(|partition| partition.execute())
470                .try_flatten_unordered(Some(6))
471                .try_collect::<Vec<_>>(),
472        )?;
473        let values = collect_scan_values(chunks.into_iter().map(Ok))?;
474
475        assert_eq!(values.len(), 3);
476        Ok(())
477    }
478
479    /// With a sparse selection and no filter, each split contributes only its *selected* rows, so
480    /// the scan must keep producing splits until the limit is actually filled.
481    #[rstest]
482    #[case::ordered(true)]
483    #[case::unordered(false)]
484    fn sparse_selection_with_limit_fills_the_limit(#[case] ordered: bool) -> VortexResult<()> {
485        let runtime = SingleThreadRuntime::default();
486        let session = session_with_handle(runtime.handle());
487        let source = LayoutReaderDataSource::new(Arc::new(TestLayoutReader::new(12)), session)
488            .with_split_max_row_count(2);
489
490        // Every two-row split contains exactly one selected row (the even ones).
491        let selection =
492            Selection::IncludeByIndex(StrictSortedBuffer::try_new(Buffer::from_iter([
493                0u64, 2, 4, 6, 8, 10,
494            ]))?);
495        let scan = runtime.block_on(source.scan(ScanRequest {
496            selection,
497            limit: Some(3),
498            ordered,
499            ..Default::default()
500        }))?;
501        let partitions = runtime.block_on(scan.partitions().try_collect::<Vec<_>>())?;
502
503        let mut values = Vec::new();
504        for partition in partitions {
505            values.extend(collect_scan_values(
506                runtime.block_on_stream(partition.execute()?),
507            )?);
508        }
509
510        assert_eq!(values, [0, 2, 4]);
511        Ok(())
512    }
513}